次の方法で共有


クラスまたは構造体の値の等価性を定義する方法 (C# プログラミング ガイド)

ヒント

最初に レコード を使用することを検討してください。 レコードでは、最小限のコードで値の等価性が自動的に実装されるため、ほとんどのデータに焦点を当てた型に対して推奨されるアプローチになります。 カスタム値等価ロジックが必要な場合、またはレコードを使用できない場合は、以下の手動実装手順に進んでください。

クラスまたは構造体を定義する場合は、型に値の等価性 (同値) のカスタム定義を作成することが有用かどうかを判断します。 通常、値の等価性を実装するのは、その型のオブジェクトをコレクションに追加する予定である場合、またはそれらの主な目的が一連のフィールドまたはプロパティを格納することである場合です。 値の等価性は、型のすべてのフィールドおよびプロパティの比較に基づいて定義できます。また、サブセットに基づいて定義することもできます。

いずれの場合も、クラスおよび構造体の両方について、等価性を保証する 5 つの条件に従って実装する必要があります (次のルールの場合、xyz が null ではないと想定しています)。

  1. 再帰プロパティ: x.Equals(x) から true が返されます。

  2. 対称プロパティ: x.Equals(y) から、y.Equals(x) と同じ値が返されます。

  3. 推移性プロパティ: (x.Equals(y) && y.Equals(z)) から true が返される場合は、x.Equals(z) からは true が返されます。

  4. x.Equals(y) が連続して呼び出された場合は、x と y によって参照されるオブジェクトが変更されていない限り、同じ値を返します。

  5. 非 null 値は null と等しくありません。 そのため、x.Equals(y) が null である場合は、x から例外がスローされます。 それにより、Equals の引数に基づき、ルール 1 または 2 が破られます。

構造体を定義すると、System.ValueType メソッドの Object.Equals(Object) オーバーライドから継承された値の等価性が既定で実装されます。 この実装では、リフレクションを使用して、型のフィールドとプロパティをすべて調べます。 この実装によって正しい結果が生成されますが、その型専用に記述したカスタム実装と比較すると、処理にかなり時間がかかります。

値の等価性に関する実装の詳細は、クラスと構造体で異なりますが、 等価性を実装するための基本的な手順については、両方とも同じです。

  1. 仮想Object.Equals(Object) メソッドをオーバーライドします。 これにより、ポリモーフィックな等値動作が提供され、 object 参照として扱われるときにオブジェクトを正しく比較できます。 コレクションでの適切な動作とポリモーフィズムの使用時の動作が保証されます。 ほとんどの場合、bool Equals( object obj ) の実装には、Equals インターフェイスの実装である型固有の System.IEquatable<T> メソッドを呼び出すだけで済みます (手順 2 を参照)。

  2. 型固有の System.IEquatable<T> メソッドを指定して、Equals インターフェイスを実装します。 ボックス化を行わずにタイプセーフな等価性チェックを実現し、パフォーマンスが向上します。 また、不要なキャストを回避し、コンパイル時の型チェックを有効にします。 ここで実際の等価性の比較を実行します。 たとえば、型のフィールドを 1 ~ 2 個だけ比較することで等価性を定義できます。 Equals から例外をスローしないでください。 継承によって関連付けられているクラスの場合:

    • このメソッドはクラスで宣言されているフィールドのみを調べます。 基底クラスに含まれるフィールドを調べるには、base.Equals を呼び出す必要があります (型が base.Equals から直接継承されている場合は、Object を呼び出さないでください。ObjectObject.Equals(Object) 実装によって、参照の等価性チェックが実行されるためです)。

    • 比較対象の変数の実行時の型が同じである場合にのみ、2 つの変数は等しいと見なされます。 また、変数の実行時とコンパイル時の型が異なる場合は、実行時の型の IEquatable メソッドの Equals 実装を必ず使用してください。 実行時の型が常に正しく比較されるようにするための方法の 1 つは、IEquatable クラスにのみ sealed を実装することです。 詳細については、この記事で後述する「クラスの例」を参照してください。

  3. 省略可能、ただし推奨: ==!= 演算子をオーバーロードします。 これにより、等価比較のための一貫性のある直感的な構文が提供され、組み込みの型からのユーザーの期待に一致します。 これにより、 obj1 == obj2obj1.Equals(obj2) が同じように動作します。

  4. 値の等価性を持つ 2 つのオブジェクトによって同じハッシュ コードが生成されるように、Object.GetHashCode をオーバーライドします。 これは、 Dictionary<TKey,TValue>HashSet<T>などのハッシュ ベースのコレクションでの正しい動作に必要です。 等しいオブジェクトは、等しいハッシュ コードを持つ必要があります。そうしないと、これらのコレクションが正しく機能しません。

  5. 省略可能: "大なり" または "小なり" の定義をサポートするには、型に対して IComparable<T> インターフェイスを実装したうえで、<= 演算子および >= 演算子をオーバーロードします。 これにより、並べ替え操作が可能になり、型の完全な順序関係が提供されます。並べ替えられたコレクションにオブジェクトを追加する場合や、配列またはリストを並べ替えるときに便利です。

レコードの例

次の例は、レコードが最小限のコードで値の等価性を自動的に実装する方法を示しています。 最初のレコード TwoDPoint は、値の等価性を自動的に実装する単純なレコード型です。 2 つ目のレコード ThreeDPoint は、レコードを他のレコードから派生させ、適切な値の等価動作を維持できることを示しています。

namespace ValueEqualityRecord;

public record TwoDPoint(int X, int Y);

public record ThreeDPoint(int X, int Y, int Z) : TwoDPoint(X, Y);

class Program
{
    static void Main(string[] args)
    {
        // Create some points
        TwoDPoint pointA = new TwoDPoint(3, 4);
        TwoDPoint pointB = new TwoDPoint(3, 4);
        TwoDPoint pointC = new TwoDPoint(5, 6);
        
        ThreeDPoint point3D_A = new ThreeDPoint(3, 4, 5);
        ThreeDPoint point3D_B = new ThreeDPoint(3, 4, 5);
        ThreeDPoint point3D_C = new ThreeDPoint(3, 4, 7);

        Console.WriteLine("=== Value Equality with Records ===");
        
        // Value equality works automatically
        Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}"); // True
        Console.WriteLine($"pointA == pointB = {pointA == pointB}"); // True
        Console.WriteLine($"pointA.Equals(pointC) = {pointA.Equals(pointC)}"); // False
        Console.WriteLine($"pointA == pointC = {pointA == pointC}"); // False

        Console.WriteLine("\n=== Hash Codes ===");
        
        // Equal objects have equal hash codes automatically
        Console.WriteLine($"pointA.GetHashCode() = {pointA.GetHashCode()}");
        Console.WriteLine($"pointB.GetHashCode() = {pointB.GetHashCode()}");
        Console.WriteLine($"pointC.GetHashCode() = {pointC.GetHashCode()}");
        
        Console.WriteLine("\n=== Inheritance with Records ===");
        
        // Inheritance works correctly with value equality
        Console.WriteLine($"point3D_A.Equals(point3D_B) = {point3D_A.Equals(point3D_B)}"); // True
        Console.WriteLine($"point3D_A == point3D_B = {point3D_A == point3D_B}"); // True
        Console.WriteLine($"point3D_A.Equals(point3D_C) = {point3D_A.Equals(point3D_C)}"); // False
        
        // Different types are not equal (unlike problematic class example)
        Console.WriteLine($"pointA.Equals(point3D_A) = {pointA.Equals(point3D_A)}"); // False
        
        Console.WriteLine("\n=== Collections ===");
        
        // Works seamlessly with collections
        var pointSet = new HashSet<TwoDPoint> { pointA, pointB, pointC };
        Console.WriteLine($"Set contains {pointSet.Count} unique points"); // 2 unique points
        
        var pointDict = new Dictionary<TwoDPoint, string>
        {
            { pointA, "First point" },
            { pointC, "Different point" }
        };
        
        // Demonstrate that equivalent points work as the same key
        var duplicatePoint = new TwoDPoint(3, 4);
        Console.WriteLine($"Dictionary contains key for {duplicatePoint}: {pointDict.ContainsKey(duplicatePoint)}"); // True
        Console.WriteLine($"Dictionary contains {pointDict.Count} entries"); // 2 entries
        
        Console.WriteLine("\n=== String Representation ===");
        
        // Automatic ToString implementation
        Console.WriteLine($"pointA.ToString() = {pointA}");
        Console.WriteLine($"point3D_A.ToString() = {point3D_A}");

        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}

/* Expected Output:
=== Value Equality with Records ===
pointA.Equals(pointB) = True
pointA == pointB = True
pointA.Equals(pointC) = False
pointA == pointC = False

=== Hash Codes ===
pointA.GetHashCode() = -1400834708
pointB.GetHashCode() = -1400834708
pointC.GetHashCode() = -148136000

=== Inheritance with Records ===
point3D_A.Equals(point3D_B) = True
point3D_A == point3D_B = True
point3D_A.Equals(point3D_C) = False
pointA.Equals(point3D_A) = False

=== Collections ===
Set contains 2 unique points
Dictionary contains key for TwoDPoint { X = 3, Y = 4 }: True
Dictionary contains 2 entries

=== String Representation ===
pointA.ToString() = TwoDPoint { X = 3, Y = 4 }
point3D_A.ToString() = ThreeDPoint { X = 3, Y = 4, Z = 5 }
*/

レコードには、値の等価性に関するいくつかの利点があります。

  • 自動実装: レコードは、 System.IEquatable<T> を自動的に実装し、 Object.EqualsObject.GetHashCode、および ==/!= 演算子をオーバーライドします。
  • 正しい継承動作: レコードは、両方のオペランドのランタイム型をチェックする仮想メソッドを使用して IEquatable<T> を実装し、継承階層とポリモーフィックなシナリオでの正しい動作を保証します。
  • 既定では不変性: レコードは不変設計を推奨します。これは、値の等値セマンティクスで適切に機能します。
  • 簡潔な構文: 位置指定パラメーターは、データ型を定義するためのコンパクトな方法を提供します。
  • パフォーマンスの向上: コンパイラによって生成された等値実装は最適化されており、既定の構造体実装のようにリフレクションを使用しません。

データの格納が主な目的であり、値の等価セマンティクスが必要な場合は、レコードを使用します。

参照の等価性を使用するメンバーを含むレコード

参照等価を使用するメンバーがレコードに含まれている場合、レコードの自動値等価動作は期待どおりに機能しません。 これは、値ベースの等価性を実装していない System.Collections.Generic.List<T>、配列、その他の参照型などのコレクションに適用されます (値の等価性を実装する System.Stringの注目すべき例外を除く)。

重要

レコードは基本的なデータ型に対して優れた値の等価性を提供しますが、参照の等価性を使用するメンバーの値の等価性は自動的に解決されません。 レコードに値の等価性を実装していない System.Collections.Generic.List<T>System.Array、またはその他の参照型が含まれている場合、それらのメンバー内の同じコンテンツを持つ 2 つのレコード インスタンスは、参照の等価性を使用するため、引き続き等しくありません。

public record PersonWithHobbies(string Name, List<string> Hobbies);

var person1 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });
var person2 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });

Console.WriteLine(person1.Equals(person2)); // False - different List instances!

これは、レコードでは各メンバーの Object.Equals メソッドが使用され、コレクション型では通常、その内容を比較するのではなく、参照の等価性が使用されるためです。

この問題を次に示します。

// Records with reference-equality members don't work as expected
public record PersonWithHobbies(string Name, List<string> Hobbies);

コードを実行したときの動作を次に示します。

Console.WriteLine("=== Records with Collections - The Problem ===");

// Problem: Records with mutable collections use reference equality for the collection
var person1 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);
var person2 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);

Console.WriteLine($"person1: {person1}");
Console.WriteLine($"person2: {person2}");
Console.WriteLine($"person1.Equals(person2): {person1.Equals(person2)}"); // False! Different List instances
Console.WriteLine($"Lists have same content: {person1.Hobbies.SequenceEqual(person2.Hobbies)}"); // True
Console.WriteLine();

参照等値メンバーを持つレコードのソリューション

  • カスタム System.IEquatable<T> 実装: コンパイラによって生成された等価性を、参照と等しいメンバーのコンテンツ ベースの比較を提供する、手動でコード化されたバージョンに置き換えます。 コレクションの場合は、 Enumerable.SequenceEqual または同様のメソッドを使用して、要素ごとの比較を実装します。

  • 可能な限り値型を使用する: データを値型で表すことができるか、値の等価性を自然にサポートする不変の構造体 ( System.Numerics.Vector<T>Planeなど) を使用できるかどうかを検討してください。

  • 値ベースの等価性を持つ型を使用する: コレクションの場合は、値ベースの等価性を実装する型を使用するか、 Object.Equals をオーバーライドするカスタム コレクション型を実装して、 System.Collections.Immutable.ImmutableArray<T>System.Collections.Immutable.ImmutableList<T>などのコンテンツ ベースの比較を提供することを検討してください。

  • 参照の等価性を念頭に置いた設計: 一部のメンバーが参照等価性を使用し、それに応じてアプリケーション ロジックを設計することを受け入れ、等値が重要な場合は同じインスタンスを再利用できるようにします。

コレクションを含むレコードのカスタム等価性を実装する例を次に示します。

// A potential solution using IEquatable<T> with custom equality
public record PersonWithHobbiesFixed(string Name, List<string> Hobbies) : IEquatable<PersonWithHobbiesFixed>
{
    public virtual bool Equals(PersonWithHobbiesFixed? other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        
        // Use SequenceEqual for List comparison
        return Name == other.Name && Hobbies.SequenceEqual(other.Hobbies);
    }

    public override int GetHashCode()
    {
        // Create hash based on content, not reference
        var hashCode = new HashCode();
        hashCode.Add(Name);
        foreach (var hobby in Hobbies)
        {
            hashCode.Add(hobby);
        }
        return hashCode.ToHashCode();
    }
}

このカスタム実装は正しく機能します。

Console.WriteLine("=== Solution 1: Custom IEquatable Implementation ===");

var personFixed1 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);
var personFixed2 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);

Console.WriteLine($"personFixed1: {personFixed1}");
Console.WriteLine($"personFixed2: {personFixed2}");
Console.WriteLine($"personFixed1.Equals(personFixed2): {personFixed1.Equals(personFixed2)}"); // True! Custom equality
Console.WriteLine();

同じ問題が配列とその他のコレクション型に影響します。

// These also use reference equality - the issue persists
public record PersonWithHobbiesArray(string Name, string[] Hobbies);

public record PersonWithHobbiesImmutable(string Name, IReadOnlyList<string> Hobbies);

配列では参照の等価性も使用され、同じ予期しない結果が生成されます。

Console.WriteLine("=== Arrays Also Use Reference Equality ===");

var personArray1 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);
var personArray2 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);

Console.WriteLine($"personArray1: {personArray1}");
Console.WriteLine($"personArray2: {personArray2}");
Console.WriteLine($"personArray1.Equals(personArray2): {personArray1.Equals(personArray2)}"); // False! Arrays use reference equality too
Console.WriteLine($"Arrays have same content: {personArray1.Hobbies.SequenceEqual(personArray2.Hobbies)}"); // True
Console.WriteLine();

読み取り時のコレクションでも、この参照等価動作が示されます。

Console.WriteLine("=== Same Issue with IReadOnlyList ===");

var personImmutable1 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);
var personImmutable2 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);

Console.WriteLine($"personImmutable1: {personImmutable1}");
Console.WriteLine($"personImmutable2: {personImmutable2}");
Console.WriteLine($"personImmutable1.Equals(personImmutable2): {personImmutable1.Equals(personImmutable2)}"); // False! Reference equality
Console.WriteLine($"Content is the same: {personImmutable1.Hobbies.SequenceEqual(personImmutable2.Hobbies)}"); // True
Console.WriteLine();

重要な分析情報は、レコードが 構造 の等価性の問題を解決するが、格納されている型の セマンティック 等値の動作を変更しないということです。

クラスの例

次の例は、クラス (参照型) で値の等価性を実装する方法を示しています。 この手動アプローチは、レコードを使用できない場合、またはカスタムの等値ロジックが必要な場合に必要です。

namespace ValueEqualityClass;

class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

    public override bool Equals(object obj) => this.Equals(obj as TwoDPoint);

    public bool Equals(TwoDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(TwoDPoint lhs, TwoDPoint rhs) => !(lhs == rhs);
}

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

    public override bool Equals(object obj) => this.Equals(obj as ThreeDPoint);

    public bool Equals(ThreeDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Check properties that this class declares.
        if (Z == p.Z)
        {
            // Let base class check its own fields
            // and do the run-time type comparison.
            return base.Equals((TwoDPoint)p);
        }
        else
        {
            return false;
        }
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint lhs, ThreeDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(ThreeDPoint lhs, ThreeDPoint rhs) => !(lhs == rhs);
}

class Program
{
    static void Main(string[] args)
    {
        ThreeDPoint pointA = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointB = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointC = null;
        int i = 5;

        Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}");
        Console.WriteLine($"pointA == pointB = {pointA == pointB}");
        Console.WriteLine($"null comparison = {pointA.Equals(pointC)}");
        Console.WriteLine($"Compare to some other type = {pointA.Equals(i)}");

        TwoDPoint pointD = null;
        TwoDPoint pointE = null;

        Console.WriteLine($"Two null TwoDPoints are equal: {pointD == pointE}");

        pointE = new TwoDPoint(3, 4);
        Console.WriteLine($"(pointE == pointA) = {pointE == pointA}");
        Console.WriteLine($"(pointA == pointE) = {pointA == pointE}");
        Console.WriteLine($"(pointA != pointE) = {pointA != pointE}");

        System.Collections.ArrayList list = new System.Collections.ArrayList();
        list.Add(new ThreeDPoint(3, 4, 5));
        Console.WriteLine($"pointE.Equals(list[0]): {pointE.Equals(list[0])}");

        // Keep the console window open in debug mode.
        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}

/* Output:
    pointA.Equals(pointB) = True
    pointA == pointB = True
    null comparison = False
    Compare to some other type = False
    Two null TwoDPoints are equal: True
    (pointE == pointA) = False
    (pointA == pointE) = False
    (pointA != pointE) = True
    pointE.Equals(list[0]): False
*/

クラス (参照型) の場合、両方の Object.Equals(Object) メソッドの既定の実装で、参照の等価性の比較は実行されますが、値の等価性のチェックは実行されません。 実装が仮想メソッドをオーバーライドする場合、その目的は、仮想メソッドに値の等価性のセマンティクスを提供することです。

== 演算子と != 演算子は、オーバーロードされなくてもクラスで使用できます。 ただし、既定の動作として参照の等価性のチェックが実行されます。 クラスで Equals メソッドをオーバーロードする場合は、== 演算子と != 演算子をオーバーロードすることをお勧めしますが、必須ではありません。

重要

前のコード例では、すべての継承シナリオが期待どおりに処理されるとは限りません。 次のコードがあるとします。

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
Console.WriteLine(p1.Equals(p2)); // output: True

このコードを実行すると、p1 の値が異なるにもかかわらず、p2z に等しいと報告されます。 この違いが無視される理由は、コンパイルではコンパイル時の型に基づいて TwoDPointIEquatable 実装のみが選択されることにあります。 これは、継承階層でのポリモーフィックな等価性に関する基本的な問題です。

ポリモーフィックな等価性

クラスを使用して継承階層に値の等価性を実装する場合、クラスの例に示されている標準的なアプローチは、オブジェクトがポリモーフィックに使用されている場合に誤った動作を引き出す可能性があります。 この問題は、 System.IEquatable<T> の実装がランタイム型ではなくコンパイル時の型に基づいて選択されるために発生します。

標準実装の問題

この問題のあるシナリオを考えてみましょう。

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);  // Declared as TwoDPoint
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);  // Declared as TwoDPoint
Console.WriteLine(p1.Equals(p2)); // True - but should be False!

コンパイラは宣言された型に基づいてTwoDPoint.Equals(TwoDPoint)を選択し、Z座標の違いを無視するため、比較によってTrueが返されます。

ポリモーフィックな等価性を修正する鍵は、すべての等値比較で仮想 Object.Equals メソッドを使用できるようにすることです。このメソッドを使用すると、ランタイムの型を確認し、継承を正しく処理できます。 これを実現するには、仮想メソッドにデリゲートする System.IEquatable<T> に対して明示的なインターフェイス実装を使用します。

基本クラスは、キー パターンを示しています。

// Safe polymorphic equality implementation using explicit interface implementation
class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

    public override bool Equals(object? obj) => Equals(obj as TwoDPoint);

    // Explicit interface implementation prevents compile-time type issues
    bool IEquatable<TwoDPoint>.Equals(TwoDPoint? p) => Equals((object?)p);

    protected virtual bool Equals(TwoDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint? lhs, TwoDPoint? rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(TwoDPoint? lhs, TwoDPoint? rhs) => !(lhs == rhs);
}

派生クラスは、等値ロジックを正しく拡張します。

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

    public override bool Equals(object? obj) => Equals(obj as ThreeDPoint);

    // Explicit interface implementation prevents compile-time type issues
    bool IEquatable<ThreeDPoint>.Equals(ThreeDPoint? p) => Equals((object?)p);

    protected override bool Equals(TwoDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Runtime type check happens in the base method
        if (p is ThreeDPoint threeD)
        {
            // Check properties that this class declares.
            if (Z != threeD.Z)
            {
                return false;
            }

            return base.Equals(p);
        }

        return false;
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint? lhs, ThreeDPoint? rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(ThreeDPoint? lhs, ThreeDPoint? rhs) => !(lhs == rhs);
}

この実装では、問題のあるポリモーフィックなシナリオがどのように処理されるかを次に示します。

Console.WriteLine("=== Safe Polymorphic Equality ===");

// Test polymorphic scenarios that were problematic before
TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
TwoDPoint p3 = new ThreeDPoint(1, 2, 3);
TwoDPoint p4 = new TwoDPoint(1, 2);

Console.WriteLine("Testing polymorphic equality (declared as TwoDPoint):");
Console.WriteLine($"p1 = ThreeDPoint(1, 2, 3) as TwoDPoint");
Console.WriteLine($"p2 = ThreeDPoint(1, 2, 4) as TwoDPoint");
Console.WriteLine($"p3 = ThreeDPoint(1, 2, 3) as TwoDPoint");
Console.WriteLine($"p4 = TwoDPoint(1, 2)");
Console.WriteLine();

Console.WriteLine($"p1.Equals(p2) = {p1.Equals(p2)}"); // False - different Z values
Console.WriteLine($"p1.Equals(p3) = {p1.Equals(p3)}"); // True - same values
Console.WriteLine($"p1.Equals(p4) = {p1.Equals(p4)}"); // False - different types
Console.WriteLine($"p4.Equals(p1) = {p4.Equals(p1)}"); // False - different types
Console.WriteLine();

実装では、直接型の比較も正しく処理されます。

// Test direct type comparisons
var point3D_A = new ThreeDPoint(3, 4, 5);
var point3D_B = new ThreeDPoint(3, 4, 5);
var point3D_C = new ThreeDPoint(3, 4, 7);
var point2D_A = new TwoDPoint(3, 4);

Console.WriteLine("Testing direct type comparisons:");
Console.WriteLine($"point3D_A.Equals(point3D_B) = {point3D_A.Equals(point3D_B)}"); // True
Console.WriteLine($"point3D_A.Equals(point3D_C) = {point3D_A.Equals(point3D_C)}"); // False
Console.WriteLine($"point3D_A.Equals(point2D_A) = {point3D_A.Equals(point2D_A)}"); // False
Console.WriteLine($"point2D_A.Equals(point3D_A) = {point2D_A.Equals(point3D_A)}"); // False
Console.WriteLine();

等値の実装は、コレクションでも適切に機能します。

// Test with collections
Console.WriteLine("Testing with collections:");
var hashSet = new HashSet<TwoDPoint> { p1, p2, p3, p4 };
Console.WriteLine($"HashSet contains {hashSet.Count} unique points"); // Should be 3: one ThreeDPoint(1,2,3), one ThreeDPoint(1,2,4), one TwoDPoint(1,2)

var dictionary = new Dictionary<TwoDPoint, string>
{
    { p1, "First 3D point" },
    { p2, "Second 3D point" },
    { p4, "2D point" }
};

Console.WriteLine($"Dictionary contains {dictionary.Count} entries");
Console.WriteLine($"Dictionary lookup for equivalent point: {dictionary.ContainsKey(new ThreeDPoint(1, 2, 3))}"); // True

上記のコードは、値に基づく等価性を実装するための重要な要素を示しています。

  • 仮想 Equals(object?) オーバーライド: メインの等値ロジックは、コンパイル時の種類に関係なく呼び出される仮想 Object.Equals メソッドで発生します。
  • ランタイム型チェック: this.GetType() != p.GetType() を使用すると、異なる型のオブジェクトが等しいとは見なされません。
  • 明示的なインターフェイス実装: System.IEquatable<T> 実装は仮想メソッドにデリゲートされ、コンパイル時の型選択の問題を回避します。
  • 保護された仮想ヘルパー メソッド: protected virtual Equals(TwoDPoint? p) メソッドを使用すると、型の安全性を維持しながら、派生クラスで等値ロジックをオーバーライドできます。

このパターンは、次の場合に使用します。

  • 値の等価性が重要な継承階層がある
  • オブジェクトはポリモーフィックに使用できます (基本型として宣言され、派生型としてインスタンス化されます)。
  • 値の等価セマンティクスを持つ参照型が必要です

推奨される方法は、 record 型を使用して値ベースの等価性を実装することです。 このアプローチでは、標準的なアプローチよりも複雑な実装が必要であり、正確性を確保するためにポリモーフィックなシナリオを徹底的にテストする必要があります。

構造体の例

次の例は、構造体 (値型) に値の等価性を実装する方法を示しています。 構造体は既定値の等価性を持ちますが、カスタム実装ではパフォーマンスを向上させることができます。

namespace ValueEqualityStruct
{
    struct TwoDPoint : IEquatable<TwoDPoint>
    {
        public int X { get; private set; }
        public int Y { get; private set; }

        public TwoDPoint(int x, int y)
            : this()
        {
            if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
            {
                throw new ArgumentException("Point must be in range 1 - 2000");
            }
            X = x;
            Y = y;
        }

        public override bool Equals(object? obj) => obj is TwoDPoint other && this.Equals(other);

        public bool Equals(TwoDPoint p) => X == p.X && Y == p.Y;

        public override int GetHashCode() => (X, Y).GetHashCode();

        public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs) => lhs.Equals(rhs);

        public static bool operator !=(TwoDPoint lhs, TwoDPoint rhs) => !(lhs == rhs);
    }

    class Program
    {
        static void Main(string[] args)
        {
            TwoDPoint pointA = new TwoDPoint(3, 4);
            TwoDPoint pointB = new TwoDPoint(3, 4);
            int i = 5;

            // True:
            Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}");
            // True:
            Console.WriteLine($"pointA == pointB = {pointA == pointB}");
            // True:
            Console.WriteLine($"object.Equals(pointA, pointB) = {object.Equals(pointA, pointB)}");
            // False:
            Console.WriteLine($"pointA.Equals(null) = {pointA.Equals(null)}");
            // False:
            Console.WriteLine($"(pointA == null) = {pointA == null}");
            // True:
            Console.WriteLine($"(pointA != null) = {pointA != null}");
            // False:
            Console.WriteLine($"pointA.Equals(i) = {pointA.Equals(i)}");
            // CS0019:
            // Console.WriteLine($"pointA == i = {pointA == i}");

            // Compare unboxed to boxed.
            System.Collections.ArrayList list = new System.Collections.ArrayList();
            list.Add(new TwoDPoint(3, 4));
            // True:
            Console.WriteLine($"pointA.Equals(list[0]): {pointA.Equals(list[0])}");

            // Compare nullable to nullable and to non-nullable.
            TwoDPoint? pointC = null;
            TwoDPoint? pointD = null;
            // False:
            Console.WriteLine($"pointA == (pointC = null) = {pointA == pointC}");
            // True:
            Console.WriteLine($"pointC == pointD = {pointC == pointD}");

            TwoDPoint temp = new TwoDPoint(3, 4);
            pointC = temp;
            // True:
            Console.WriteLine($"pointA == (pointC = 3,4) = {pointA == pointC}");

            pointD = temp;
            // True:
            Console.WriteLine($"pointD == (pointC = 3,4) = {pointD == pointC}");

            Console.WriteLine("Press any key to exit.");
            Console.ReadKey();
        }
    }

    /* Output:
        pointA.Equals(pointB) = True
        pointA == pointB = True
        Object.Equals(pointA, pointB) = True
        pointA.Equals(null) = False
        (pointA == null) = False
        (pointA != null) = True
        pointA.Equals(i) = False
        pointE.Equals(list[0]): True
        pointA == (pointC = null) = False
        pointC == pointD = True
        pointA == (pointC = 3,4) = True
        pointD == (pointC = 3,4) = True
    */
}

構造体の場合、Object.Equals(Object) (System.ValueType でオーバーライドされるバージョン) の既定の実装で、リフレクションを使用して値の等価性のチェックが実行され、型のすべてのフィールドの値が比較されます。 この実装によって正しい結果が生成されますが、その型専用に記述したカスタム実装と比較すると、処理にかなり時間がかかります。

構造体内の仮想 Equals メソッドをオーバーライドする場合、その目的は、値の等価性チェックを実行するより効率的な手段を提供し、必要に応じて、構造体のフィールドまたはプロパティのサブセットに基づいて比較を行います。

== および != 演算子を使用して構造体で操作することは、その構造体によってそれらの演算子が明示的にオーバーロードされない限り、不可能です。

関連項目