Bagikan melalui


Cara menentukan kesetaraan nilai untuk kelas atau struktur (Panduan Pemrograman C#)

Petunjuk / Saran

Pertimbangkan untuk menggunakan rekaman terlebih dahulu. Rekaman secara otomatis menerapkan kesetaraan nilai dengan kode minimal, menjadikannya pendekatan yang direkomendasikan untuk sebagian besar jenis yang berfokus pada data. Jika Anda memerlukan logika kesetaraan nilai kustom atau tidak dapat menggunakan rekaman, lanjutkan dengan langkah-langkah implementasi manual di bawah ini.

Saat Anda menentukan kelas atau struktur, Anda memutuskan apakah masuk akal untuk membuat definisi kustom kesetaraan nilai (atau yang setara) untuk jenis tersebut. Biasanya, Anda menerapkan kesetaraan nilai ketika Anda ingin menambahkan objek jenis ke koleksi, atau ketika tujuan utamanya adalah untuk menyimpan sekumpulan bidang atau properti. Anda dapat mendasarkan definisi kesetaraan nilai Anda pada perbandingan semua bidang dan properti dalam jenis, atau Anda dapat mendasarkan definisi pada subset.

Dalam kedua kasus, dan dalam kedua kelas dan struktur, implementasi Anda harus mengikuti lima jaminan kesetaraan (untuk aturan berikut, asumsikan bahwa x, y dan z tidak null):

  1. Properti refleksif: x.Equals(x) mengembalikan true.

  2. Properti simetris: x.Equals(y) mengembalikan nilai yang sama dengan y.Equals(x).

  3. Properti transitif: jika (x.Equals(y) && y.Equals(z)) mengembalikan true, maka x.Equals(z) mengembalikan true.

  4. Pemanggilan x.Equals(y) berturut-turut mengembalikan nilai yang sama selama objek yang dirujuk oleh x dan y tidak dimodifikasi.

  5. Nilai non-null tidak sama dengan null. Namun, x.Equals(y) melempar pengecualian ketika x adalah null. Itu melanggar aturan 1 atau 2, tergantung pada argumen pada Equals.

Setiap struktur yang Anda tentukan sudah memiliki implementasi default kesetaraan nilai yang diwarisinya dari penggantian System.ValueType pada metode Object.Equals(Object). Implementasi ini menggunakan refleksi untuk memeriksa semua bidang dan properti dalam jenis. Meskipun implementasi ini menciptakan hasil yang benar, itu justru relatif lambat dibandingkan dengan implementasi kustom yang Anda tulis khusus untuk jenis tersebut.

Detail implementasi untuk kesetaraan nilai berbeda untuk kelas dan struktur. Namun, kelas dan struktur memerlukan langkah-langkah dasar yang sama untuk menerapkan kesetaraan:

  1. Ambil alih metode virtualObject.Equals(Object). Ini memberikan perilaku kesetaraan polimorfik, memungkinkan objek Anda dibandingkan dengan benar saat diperlakukan sebagai object referensi. Ini memastikan perilaku yang tepat dalam koleksi dan saat menggunakan polimorfisme. Dalam kebanyakan kasus, implementasi Anda terhadap bool Equals( object obj ) hanya boleh memanggil metode khusus jenis Equals yang merupakan implementasi antarmuka System.IEquatable<T>. (Lihat langkah 2.)

  2. Terapkan antarmuka System.IEquatable<T> dengan menyediakan metode khusus jenis Equals. Ini memberikan pemeriksaan kesetaraan jenis aman tanpa tinju, menghasilkan performa yang lebih baik. Ini juga menghindari transmisi yang tidak perlu dan memungkinkan pemeriksaan jenis waktu kompilasi. Di sinilah perbandingan kesetaraan aktual dilakukan. Misalnya, Anda mungkin memutuskan untuk menentukan kesetaraan dengan hanya membandingkan satu atau dua bidang dalam jenis Anda. Jangan membuat pengecualian dari Equals. Untuk kelas yang terkait dengan pewarisan:

    • Metode ini hanya boleh memeriksa bidang yang dideklarasikan dalam kelas. Ini harus memanggil base.Equals untuk memeriksa bidang yang ada di kelas dasar. (Jangan panggil base.Equals jika jenis mewarisi langsung dari Object, karena implementasi Object dari Object.Equals(Object) melakukan pemeriksaan kesetaraan referensi.)

    • Dua variabel harus dianggap sama hanya jika jenis run-time variabel yang dibandingkan sama. Selain itu, pastikan bahwa IEquatable implementasi Equals metode untuk jenis run-time digunakan jika jenis run-time dan compile-time variabel berbeda. Salah satu strategi untuk memastikan tipe run-time selalu dibandingkan dengan benar adalah menerapkan IEquatable hanya di kelas sealed. Untuk informasi selengkapnya, lihat contoh kelas selanjutnya di artikel ini.

  3. Opsional tetapi disarankan: Kelebihan beban operator == dan !=. Ini memberikan sintaks yang konsisten dan intuitif untuk perbandingan kesetaraan, mencocokkan ekspektasi pengguna dari jenis bawaan. Ini memastikan bahwa obj1 == obj2 dan obj1.Equals(obj2) berulah dengan cara yang sama.

  4. Ambil alih Object.GetHashCode sehingga dua objek yang memiliki kesetaraan nilai menghasilkan hash yang sama. Ini diperlukan untuk perilaku yang benar dalam koleksi berbasis hash seperti Dictionary<TKey,TValue> dan HashSet<T>. Objek yang sama harus memiliki kode hash yang sama, atau koleksi ini tidak akan berfungsi dengan benar.

  5. Opsional: Untuk mendukung definisi "lebih besar dari" atau "kurang dari," terapkan antarmuka IComparable<T> pada tipe Anda dan juga overload operator <= dan >=. Ini memungkinkan operasi pengurutan dan menyediakan hubungan pengurutan lengkap untuk jenis Anda, berguna saat menambahkan objek ke koleksi yang diurutkan atau saat mengurutkan array atau daftar.

Contoh rekaman

Contoh berikut menunjukkan bagaimana rekaman secara otomatis menerapkan kesetaraan nilai dengan kode minimal. Rekaman TwoDPoint pertama adalah jenis catatan sederhana yang secara otomatis mengimplementasikan kesetaraan nilai. Catatan ThreeDPoint kedua menunjukkan bahwa rekaman dapat berasal dari rekaman lain dan masih mempertahankan perilaku kesetaraan nilai yang tepat:

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 }
*/

Rekaman memberikan beberapa keuntungan untuk kesetaraan nilai:

  • Implementasi otomatis: Rekaman secara otomatis mengimplementasikan System.IEquatable<T> dan mengambil Object.Equalsalih , Object.GetHashCode, dan ==/!= operator.
  • Perilaku warisan yang benar: Rekaman diterapkan IEquatable<T> menggunakan metode virtual yang memeriksa jenis runtime kedua operan, memastikan perilaku yang benar dalam hierarki pewarisan dan skenario polimorfik.
  • Kekekalan secara default: Rekaman mendorong desain yang tidak dapat diubah, yang bekerja dengan baik dengan semantik kesetaraan nilai.
  • Sintaks ringkas: Parameter posisi menyediakan cara yang ringkas untuk menentukan jenis data.
  • Performa yang lebih baik: Implementasi kesetaraan yang dihasilkan kompilator dioptimalkan dan tidak menggunakan refleksi seperti implementasi struct default.

Gunakan rekaman saat tujuan utama Anda adalah menyimpan data dan Anda memerlukan semantik kesetaraan nilai.

Rekaman dengan anggota yang menggunakan kesetaraan referensi

Saat rekaman berisi anggota yang menggunakan kesetaraan referensi, perilaku kesetaraan nilai otomatis rekaman tidak berfungsi seperti yang diharapkan. Ini berlaku untuk koleksi seperti System.Collections.Generic.List<T>, array, dan jenis referensi lainnya yang tidak menerapkan kesetaraan berbasis nilai (dengan pengecualian penting dari System.String, yang mengimplementasikan kesetaraan nilai).

Penting

Meskipun catatan memberikan kesetaraan nilai yang sangat baik untuk jenis data dasar, catatan tersebut tidak secara otomatis menyelesaikan kesetaraan nilai untuk anggota yang menggunakan kesetaraan referensi. Jika rekaman berisi System.Collections.Generic.List<T>, , System.Arrayatau jenis referensi lain yang tidak menerapkan kesetaraan nilai, dua instans rekaman dengan konten identik dalam anggota tersebut masih tidak akan sama karena anggota menggunakan kesetaraan referensi.

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!

Ini karena rekaman menggunakan Object.Equals metode setiap anggota, dan jenis koleksi biasanya menggunakan kesetaraan referensi daripada membandingkan konten mereka.

Berikut ini menunjukkan masalahnya:

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

Berikut adalah bagaimana ini berperilaku saat Anda menjalankan kode:

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();

Solusi untuk rekaman dengan anggota kesetaraan referensi

  • Implementasi kustomSystem.IEquatable<T>: Ganti kesetaraan yang dihasilkan kompilator dengan versi berkode tangan yang menyediakan perbandingan berbasis konten untuk anggota kesetaraan referensi. Untuk koleksi, terapkan perbandingan elemen demi elemen menggunakan Enumerable.SequenceEqual atau metode serupa.

  • Gunakan jenis nilai jika memungkinkan: Pertimbangkan apakah data Anda dapat diwakili dengan jenis nilai atau struktur yang tidak dapat diubah yang secara alami mendukung kesetaraan nilai, seperti System.Numerics.Vector<T> atau Plane.

  • Gunakan jenis dengan kesetaraan berbasis nilai: Untuk koleksi, pertimbangkan untuk menggunakan jenis yang menerapkan kesetaraan berbasis nilai atau menerapkan jenis koleksi kustom yang mengambil alih Object.Equals untuk memberikan perbandingan berbasis konten, seperti System.Collections.Immutable.ImmutableArray<T> atau System.Collections.Immutable.ImmutableList<T>.

  • Desain dengan mempertimbangkan kesetaraan referensi: Terima bahwa beberapa anggota akan menggunakan kesetaraan referensi dan merancang logika aplikasi Anda dengan sesuai, memastikan bahwa Anda menggunakan kembali instans yang sama ketika kesetaraan penting.

Berikut adalah contoh penerapan kesetaraan kustom untuk rekaman dengan koleksi:

// 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();
    }
}

Implementasi kustom ini berfungsi dengan benar:

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();

Masalah yang sama memengaruhi array dan jenis koleksi lainnya:

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

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

Array juga menggunakan kesetaraan referensi, menghasilkan hasil yang tidak terduga yang sama:

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();

Bahkan koleksi readonly menunjukkan perilaku kesetaraan referensi ini:

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();

Wawasan utama adalah bahwa rekaman memecahkan masalah kesetaraan struktural tetapi tidak mengubah perilaku kesetaraan semantik dari jenis yang dikandungnya.

Contoh kelas

Contoh berikut menunjukkan cara menerapkan kesetaraan nilai dalam kelas (jenis referensi). Pendekatan manual ini diperlukan ketika Anda tidak dapat menggunakan rekaman atau memerlukan logika kesetaraan kustom:

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
*/

Pada kelas (jenis referensi), implementasi default pada kedua metode Object.Equals(Object) melakukan perbandingan kesetaraan referensi, bukan pemeriksaan kesetaraan nilai. Ketika seorang pengimplementasi menggantikan metode virtual, tujuannya adalah untuk memberikannya semantik kesetaraan nilai.

Operator == dan != dapat digunakan dengan kelas bahkan jika kelas tidak membebaninya. Namun, perilaku default berfungsi untuk melakukan pemeriksaan kesetaraan referensi. Dalam kelas, jika Anda meng-overload metode Equals, dianjurkan untuk meng-overload operator == dan !=, tetapi tidak diperlukan.

Penting

Contoh kode sebelumnya mungkin tidak menangani setiap skenario pewarisan seperti yang Anda harapkan. Pertimbangkan kode berikut:

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

Kode ini melaporkan bahwa p1 sama dengan p2 meskipun ada perbedaan pada nilai z. Perbedaan diabaikan karena pengompilasi memilih implementasi TwoDPoint berdasarkan jenis waktu kompilasi IEquatable. Ini adalah masalah mendasar dengan kesetaraan polimorfik dalam hierarki warisan.

Kesetaraan polimorfik

Saat menerapkan kesetaraan nilai dalam hierarki pewarisan dengan kelas, pendekatan standar yang ditunjukkan dalam contoh kelas dapat menyebabkan perilaku yang salah ketika objek digunakan secara polimorfik. Masalah ini terjadi karena System.IEquatable<T> implementasi dipilih berdasarkan jenis waktu kompilasi, bukan jenis runtime.

Masalah dengan implementasi standar

Pertimbangkan skenario bermasalah ini:

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!

Perbandingan TrueZ mengembalikan karena kompilator memilih TwoDPoint.Equals(TwoDPoint) berdasarkan jenis yang dideklarasikan, mengabaikan perbedaan koordinat.

Kunci untuk memperbaiki kesetaraan polimorfik adalah memastikan bahwa semua perbandingan kesetaraan menggunakan metode virtual Object.Equals , yang dapat memeriksa jenis runtime dan menangani warisan dengan benar. Ini dapat dicapai dengan menggunakan implementasi antarmuka eksplisit untuk System.IEquatable<T> yang mendelegasikan ke metode virtual:

Kelas dasar menunjukkan pola utama:

// 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);
}

Kelas turunan dengan benar memperluas logika kesetaraan:

// 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);
}

Berikut adalah bagaimana implementasi ini menangani skenario polimorfik yang bermasalah:

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();

Implementasi juga menangani perbandingan jenis langsung dengan benar:

// 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();

Implementasi kesetaraan juga berfungsi dengan baik dengan koleksi:

// 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

Kode sebelumnya menunjukkan elemen kunci untuk menerapkan kesetaraan berbasis nilai:

  • Penimpaan Equals(object?)virtual: Logika kesetaraan utama terjadi dalam metode virtualObject.Equals, yang dipanggil terlepas dari jenis waktu kompilasi.
  • Pemeriksaan jenis runtime: Menggunakan this.GetType() != p.GetType() memastikan bahwa objek dari berbagai jenis tidak pernah dianggap sama.
  • Implementasi antarmuka eksplisit: Implementasi System.IEquatable<T> mendelegasikan ke metode virtual, mencegah masalah pemilihan jenis waktu kompilasi.
  • Metode pembantu virtual yang dilindungi: Metode ini protected virtual Equals(TwoDPoint? p) memungkinkan kelas turunan untuk mengambil alih logika kesetaraan sambil menjaga keamanan jenis.

Gunakan pola ini ketika:

  • Anda memiliki hierarki pewarisan di mana kesetaraan nilai penting
  • Objek mungkin digunakan secara polimorfik (dinyatakan sebagai jenis dasar, dibuat sebagai jenis turunan)
  • Anda memerlukan jenis referensi dengan semantik kesetaraan nilai

Pendekatan yang disukai adalah menggunakan record jenis untuk mengimplementasikan kesetaraan berbasis nilai. Pendekatan ini membutuhkan implementasi yang lebih kompleks daripada pendekatan standar dan memerlukan pengujian menyeluruh skenario polimorfik untuk memastikan kebenaran.

Contoh struktur

Contoh berikut menunjukkan cara mengimplementasikan kesetaraan nilai dalam struct (jenis nilai). Meskipun struktur memiliki kesetaraan nilai default, implementasi kustom dapat meningkatkan performa:

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
    */
}

Untuk struktur, implementasi default Object.Equals(Object) (yang merupakan versi yang ditimpa pada System.ValueType) melakukan pemeriksaan kesetaraan nilai dengan menggunakan pantulan untuk membandingkan nilai setiap bidang dalam jenis. Meskipun implementasi ini menciptakan hasil yang benar, itu justru relatif lambat dibandingkan dengan implementasi kustom yang Anda tulis khusus untuk jenis tersebut.

Ketika Anda mengambil alih metode virtual Equals dalam struktur, tujuannya adalah untuk memberikan cara yang lebih efisien untuk melakukan pemeriksaan kesetaraan nilai dan secara opsional untuk mendasarkan perbandingan pada beberapa subset bidang atau properti struct.

Operator == dan != tidak dapat beroperasi pada struktur kecuali struktur secara eksplisit membebani mereka.

Lihat juga