C#のイコール判定は思ったより面倒だった
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。最近はドメインモデルをドキュメントデータベースでどのように綺麗に表現できるかで色々頑張っています。
使っている言語はC#、保存先はC#のイベントを保存しているのですが、最近は代数的データ型がうまく表せないC#でもインターフェースとその実装でJSON型に型を保存できる、JsonDerivedTypeを使用しています。こちらの記事「C#でインターフェースのプロパティをJSONにJsonDerivedTypeを使ってシリアライズ、デシリアライズする」でどのようにインターフェースを使ってJSONに複数の型を保存できるかを書いています。
その実装により、シンプルな型の設計でドメインモデルを表現できるため、フラグ的なものがなくなり、フラグがなくなることにより、型と型の変換をファンクションで表現することにより、値オブジェクトの設計をしっかり行ったらかなりのドメインモデルのコーディングが進むという点でかなり重宝しています。
このように作っているときによくあるコーディングのバターンが以下のものです。
- 値オブジェクトを使って、特定の計算ロジックを走らせて、保存用の値オブジェクト①を作る
- すでに保存されているデータがない場合は作成した保存用オブジェクト①を保存
- すでに保存されているデータ⓪があり、⓪と①が違っていたら①をイベントとして保存
- すでに保存されているデータ⓪があり、⓪と①が同じだったら何もしない
つまり、オブジェクトの内容が同じであるかどうかを比較することが重要です。データの条件としては以下のものです。
- データはJSONにシリアライズして保存して、デシリアライズして再度メモリに戻っているため、比較先オブジェクトは別のアドレスに保存されたもの
- シリアライズ、デシリアライズでデータが壊れない前提
- インターフェース+JsonDerivedTypeで一つの変数に複数の実装型を保存しているため、インターフェース型に入った状態からの比較が必要
そのため、比較の方法を理解しておきたいと思い、Githubにテストリポジトリを作成して検証してみました。この記事はこちらのテストの結果から書いていますので、よろしければこちらのリポジトリをご覧ください。
結論、個人的にこれから採用する方法
- JSON保存するデータに関してはrecord型を使用する
- 配列のデータがある場合はその順番も大切(個人的には保存データにはImmutableListなどのイミュータブル型を使い、保存時に並べ替えて保存をするのがいいかと思います。)
- 配列のデータがある場合はEqualsを上書きしておくのがベスト
- インターフェースの比較をする場合は、== ではなくて、Equalsを使うのがベスト
以下、説明していきたいと思います。
JSON保存するデータに関してはrecord型を使用する
C# 9.0から導入されたrecord型は、class型とは異なる特性を持つ参照型です。以下にその主な違いを説明します。
-
不変性:
record型のプライマリコンストラクタで定義したプロパティは不変型です。つまり、一度作成されたインスタンスの状態は変更できません。これに対して、class型のプロパティは可変型にも、不変型にも定義できます。レコード型のプライマリコンストラクタで定義したプロパティは、クラスのpublic int Age {get; init;}
と同じ形で定義されます。 -
メンバーの自動実装:
record型は、コンストラクタ、プロパティ、Equals、GetHashCode、ToStringなどのメソッドが自動的に生成されます。これに対して、class型ではこれらのメソッドを自分で実装する必要があります。 -
値による等価性:
record型はシンプルな値型を使用している場合、値による等価性を持ちます。つまり、プロパティの値が同じであれば、異なるインスタンスでも等しいとみなされます。これに対して、通常class型は参照による等価性を持ち、同じインスタンスを参照している場合にのみ等しいとみなされます。classにIEquatable<T>
を実装して、Equalsを上書きして、且つ==
と!=
をオーバーライドしている場合にのみ、値による等価性を持つようになります。
以下のrecordの実装とclassの実装は同じ動作をします
public record SimpleRecord(int Id, string Name);
public class SimpleClassWithEquatable : IEquatable<SimpleClassWithEquatable>
{
public SimpleClassWithEquatable(int id, string name)
{
Id = id;
Name = name;
}
public int Id { get; set; }
public string Name { get; set; }
public bool Equals(SimpleClassWithEquatable? other)
{
if (ReferenceEquals(null, other))
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return Id == other.Id && Name == other.Name;
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
if (obj.GetType() != GetType())
{
return false;
}
return Equals((SimpleClassWithEquatable)obj);
}
public override int GetHashCode()
{
unchecked
{
return (Id * 397) ^ Name.GetHashCode();
}
}
public static bool operator ==(SimpleClassWithEquatable? left, SimpleClassWithEquatable? right) => Equals(left, right);
public static bool operator !=(SimpleClassWithEquatable? left, SimpleClassWithEquatable? right) => !Equals(left, right);
}
recordを使うと比較を簡単にできるのがわかると思います。 等価チェックをするときにはrecordを使うといいですね。
配列のデータがある場合はその順番も大切
配列のデータがrecord型のプロパティにある場合は、その順番も大切です。その場合は、いかの方法で対応できます。
- 自動で並べ替えるSorted系の配列を使用する(SortedSetなど)
- データ登録時に並べ替えて登録する
このようにして、順番が正しくセットされたことを確認してから等価チェックを行うことができます。
また、配列データがListであるか、ImmutableListであるかは等価比較の点では変わりありません。ListとImmutableListの違いは、Listは可変型で、ImmutableListは不変型です。そのため、ListはAdd, Removeなどのメソッドで要素を変更できますが、ImmutableListは要素を変更することができません。そのため、ListとImmutableListの違いは、要素を変更できるかどうかの違いです。
record内に配列のデータがある場合はEqualsを上書きしておくのがベスト
record内に配列のデータがある場合、簡単に比較はできません。以下のテストはAssertでエラーになります。
// テストがこける例
public record SimpleRecordWithList(int Id, string Name, List<int> Numbers)
{
}
public class RecordsAndClassesEqualsTest_False
{
[Fact]
public void SimpleRecordWithList()
{
var record1 = new SimpleRecordWithList(1, "Test", new List<int> { 1, 2, 3 });
var record2 = new SimpleRecordWithList(1, "Test", new List<int> { 1, 2, 3 });
Assert.True(record1 == record2); // ここがfalseになる
Assert.True(record1.Equals(record2)); // ここがfalseになる
Assert.False(record1 != record2); // ここがtrueになる
Assert.False(!record1.Equals(record2));// ここがtrueになる
Assert.Equal(record1, record2); // ここがfalseになる
}
}
この場合に、EqualsとGetHashCodeを上書きしておくと、以下のようにテストが成功します。配列データに関して、SequenceEqualを使うことにより、比較が成功します。
Copilotを使うと、EqualsやGetHashCodeは簡単に書けるので、現代では楽なものです。
// このテストは成功する
public record SimpleRecordWithListWithEqualsOverrides(int Id, string Name, List<int> Numbers)
{
public virtual bool Equals(SimpleRecordWithListWithEqualsOverrides? other)
{
if (ReferenceEquals(null, other))
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return Id == other.Id && Name == other.Name && Numbers.SequenceEqual(other.Numbers);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = Id;
hashCode = (hashCode * 397) ^ Name.GetHashCode();
hashCode = (hashCode * 397) ^ Numbers.GetHashCode();
return hashCode;
}
}
}
public class RecordsAndClassesEqualsTest_True
{
[Fact]
public void SimpleRecordWithListWithEqualsOverrides()
{
var record1 = new SimpleRecordWithListWithEqualsOverrides(1, "Test", new List<int> { 1, 2, 3 });
var record2 = new SimpleRecordWithListWithEqualsOverrides(1, "Test", new List<int> { 1, 2, 3 });
Assert.True(record1 == record2);
Assert.True(record1.Equals(record2));
Assert.False(record1 != record2);
Assert.False(!record1.Equals(record2));
Assert.Equal(record1, record2);
}
}
ちなみに、Listの中が以下のものでも成功します。
- 等価チェックが正しくできるrecord
- 等価チェックが正しくできるようにEqualsやGetHashCodeを実装しているもの
それらが階層になっていても等価比較できるのは嬉しいところです。
ここまでに関しては、==とEquals, != と!Equalsは同じ動きをしていますので、混乱しにくいのですが、インターフェースの継承をすると、少し面倒な動きになります。
インターフェースの比較をする場合は、== ではなくて、Equalsを使うのがベスト
インターフェースを使った場合、比較が難しくなります。簡単に結論を書くと以下の形になります。
以下の簡単な例の場合を考えてみます。
public interface IBase
{
public string GetKey();
}
public record RecordType1(int Id, string Name) : IBase
{
public string GetKey() => $"{Id}";
}
public record RecordType2(int Id, string Name) : IBase
{
public string GetKey() => $"{Id}";
}
IBaseインターフェース型で保持しているときに同じ内容のものは == ではfalse, Equalsではtrueになる
public class InheritanceCompareTest_Mixed
{
// 下記のテストが成功する、つまり == で比較したときに内容が同じでもfalseになる
[Fact]
public void SameRecordTypeThroughInterface()
{
IBase record1 = new RecordType1(1, "Test");
IBase record2 = new RecordType1(1, "Test");
Assert.False(record1 == record2); // ここがtrueになって欲しい
Assert.True(record1.Equals(record2)); // この動きは想定した通りのもので正しく感じる
Assert.True(record1 != record2); // ここがfalseになって欲しい
Assert.False(!record1.Equals(record2));// この動きは想定した通りのもので正しく感じる
Assert.Equal(record1, record2);
}
}
object 型に保持したときは、IBaseで保持した時と同じ結果
public class InheritanceCompareTest_Mixed
{
// 下記のテストが成功する、つまり == で比較したときに内容が同じでもfalseになる
[Fact]
public void SameRecordTypeThroughObject()
{
object record1 = new RecordType1(1, "Test");
object record2 = new RecordType1(1, "Test");
Assert.False(record1 == record2);
Assert.True(record1.Equals(record2));
Assert.True(record1 != record2);
Assert.False(!record1.Equals(record2));
Assert.Equal(record1, record2);
}
}
実装型 Record1に保持した時は、==でもEqualsでも正しく比較できる
public class InheritanceCompareTest_True
{
[Fact]
public void SameRecordTypeThroughVar()
{
var record1 = new RecordType1(1, "Test");
var record2 = new RecordType1(1, "Test");
Assert.True(record1 == record2);
Assert.True(record1.Equals(record2));
Assert.False(record1 != record2);
Assert.False(!record1.Equals(record2));
Assert.Equal(record1, record2);
}
}
dynamic に変換した時は、==でもEqualsでも正しく比較できる
public class InheritanceCompareTest_True
{
[Fact]
public void SameRecordTypeThroughDynamic()
{
dynamic record1 = new RecordType1(1, "Test");
dynamic record2 = new RecordType1(1, "Test");
Assert.True(record1 == record2);
Assert.True(record1.Equals(record2));
Assert.False(record1 != record2);
Assert.False(!record1.Equals(record2));
Assert.Equal(record1, record2);
}
}
その他の細かい部分は、record型単体で使った時と同じく、配列データに関しては並べ替えを正しくして、Equals, GetHashCodeを実装していれば比較が正しくできますし、ListとImmutableListなどの違いは、比較の点では変わりありません。
おまけ、JSON化して比較するとかなりのケースで簡単に比較できる
public static class JsonEqualChecker
{
public static bool JsonSerializableAndEqual(object? d1, object? d2)
{
if (ReferenceEquals(d1, d2))
{
return true;
}
if (ReferenceEquals(null, d1))
{
return false;
}
if (ReferenceEquals(null, d2))
{
return false;
}
var json1 = JsonSerializer.Serialize(d1);
var obj1 = JsonSerializer.Deserialize(json1, d1.GetType());
var json1_1 = JsonSerializer.Serialize(obj1);
if (json1 != json1_1) { return false; }
var json2 = JsonSerializer.Serialize(d2);
var obj2 = JsonSerializer.Deserialize(json2, d2.GetType());
var json2_1 = JsonSerializer.Serialize(obj2);
if (json2 != json2_1) { return false; }
return json1 == json2;
}
}
上記のようなJSON化して比較するメソッドを使うと、いろいろ楽に比較できるようになります。個人的にはテスト内でのみ使用しています。
- パフォーマンス的には良くないので、場所を限定して比較するといいと思います
- 型は違うが、内容は同じの2つの型の相互のデータが同じになってしまう場合があります(これはならないように調整することは可能)
- テストではこのように1つのデータに関しても、jsonで保存して、取り出してまたオブジェクト化したものと元のオブジェクトを比較して同じであるかをチェックすることによって、型のシリアライズ可用性をチェックすると、実際に保存してときに問題が発生するのを未然に防げます。
まとめ
以上、少し面倒なルールとなるのですが、正しく比較ができれば正しいドメインモデルの構築に寄与しますので、上記に関してはよく理解して作成していきたいと思います。
インターフェースとその実装でデータを保存するというのはカラムの決まったデータベースではあまりやらない方法ですが、個人的にはこれによって劇的にドメインモデリングが行いやすくなったと感じています。よろしければ参考にされてください。
Discussion