다음을 통해 공유


클래스 또는 구조체에 대한 값 같음을 정의하는 방법(C# 프로그래밍 가이드)

팁 (조언)

레코드를 먼저 고려해보는 것이 좋습니다. 레코드는 최소 코드로 값 같음을 자동으로 구현하므로 대부분의 데이터 중심 형식에 권장되는 방법입니다. 사용자 지정 값 같음 논리가 필요하거나 레코드를 사용할 수 없는 경우 아래 수동 구현 단계를 계속 진행합니다.

클래스 또는 구조체를 정의할 때 형식에 대한 값 같음(또는 동등)의 사용자 지정 정의를 만드는 것이 적합한지 결정합니다. 일반적으로 형식의 개체를 컬렉션에 추가해야 하는 경우 또는 주요 용도가 필드 또는 속성 세트 저장인 경우 값 같음을 구현합니다. 형식의 모든 필드 및 속성 비교를 기준으로 값 같음의 정의를 만들거나, 하위 집합을 기준으로 정의를 만들 수 있습니다.

두 경우 모두 및 클래스와 구조체 둘 다에서 구현은 다음과 같은 동등의 5가지 보장 사항을 따라야 합니다(다음 규칙의 경우 x, y, z가 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는 예외를 throw합니다. Equals에 대한 인수에 따라 규칙 1 또는 2가 위반됩니다.

정의하는 모든 구조체에는 System.ValueType 메서드의 Object.Equals(Object) 재정의에서 상속하는 값 같음의 기본 구현이 이미 있습니다. 이 구현은 리플렉션을 사용하여 형식의 모든 필드와 속성을 검사합니다. 이 구현은 올바른 결과를 생성하지만 해당 형식에 맞게 작성한 사용자 지정 구현에 비해 비교적 속도가 느립니다.

값 같음에 대한 구현 세부 정보는 클래스 및 구조체에서 서로 다릅니다. 그러나 클래스와 구조체는 둘 다 같음 구현을 위해 동일한 기본 단계가 필요합니다.

  1. virtualObject.Equals(Object) 메서드를 재정의합니다. 이것은 다형성 동등 비교 기능을 제공하므로, 참조로 object 처리될 때 개체를 정확히 비교할 수 있도록 해줍니다. 컬렉션 및 다형성을 사용할 때 적절한 동작을 보장합니다. 대부분의 경우 bool Equals( object obj ) 구현에서 Equals 인터페이스 구현인 형식별 System.IEquatable<T> 메서드만 호출하면 됩니다. 2단계를 참조하세요.

  2. 형식별 System.IEquatable<T> 메서드를 제공하여 Equals 인터페이스를 구현합니다. 이러한 방식은 boxing 없이 타입 안전 동등성 검사를 제공하여 성능이 향상됩니다. 또한 불필요한 캐스팅을 방지하고 컴파일 시간 형식 검사를 사용하도록 설정합니다. 여기서 실제 동등 비교가 수행됩니다. 예를 들어 형식에서 한 개나 두 개의 필드만 비교하여 같음을 정의하기로 결정할 수 있습니다. Equals에서 예외를 던지지 마십시오. 상속과 관련된 클래스의 경우:

    • 이 메서드는 클래스에 선언된 필드만 검사해야 합니다. base.Equals를 호출하여 기본 클래스에 있는 필드를 검사해야 합니다. (base.EqualsObject 구현에서 참조 같음 검사를 수행하므로 형식이 Object에서 직접 상속하는 경우에는 Object.Equals(Object)를 호출하지 마세요.)

    • 비교하는 변수의 런타임 형식이 같으면 두 변수는 같은 것으로 간주되어야 합니다. 또한 변수의 런타임 형식과 컴파일 시간 형식이 다른 경우 런타임 형식에 대한 IEquatable 메서드의 Equals 구현이 사용되는지 확인합니다. 런타임 형식이 항상 올바르게 비교되도록 하기 위한 한 가지 전략은 IEquatable 클래스에서만 sealed을 구현하는 것입니다. 자세한 내용은 이 문서 뒷부분의 코드 예제를 참조하세요.

  3. 선택 사항이지만 권장됨: ==!= 연산자를 오버로드합니다. 이렇게 하면 같음 비교를 위한 일관되고 직관적인 구문이 제공되어 기본 제공 형식의 사용자 기대치와 일치합니다. 이는 obj1 == obj2obj1.Equals(obj2)가 동일한 방식으로 작동하도록 보장합니다.

  4. 값이 같은 두 개체가 동일한 해시 코드를 생성하도록 Object.GetHashCode를 재정의합니다. 이는 해시 기반 컬렉션과 같은 Dictionary<TKey,TValue>HashSet<T>올바른 동작에 필요합니다. 같은 개체는 해시 코드가 같아야 합니다. 그렇지 않으면 이러한 컬렉션이 제대로 작동하지 않습니다.

  5. 선택 사항: “보다 큼” 또는 “보다 작음”에 대한 정의를 지원하기 위해 형식에 대한 IComparable<T> 인터페이스를 구현하고 <=>= 연산자도 오버로드합니다. 이렇게 하면 정렬 작업을 사용할 수 있으며 정렬된 컬렉션에 개체를 추가하거나 배열 또는 목록을 정렬할 때 유용한 형식에 대한 전체 순서 지정 관계를 제공합니다.

레코드 예제

다음 예제에서는 레코드가 최소 코드로 값 같음을 자동으로 구현하는 방법을 보여줍니다. 첫 번째 레코드 TwoDPoint 는 값 같음을 자동으로 구현하는 간단한 레코드 형식입니다. 두 번째 레코드 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.Equals, Object.GetHashCode, ==/!= 연산자를 재정의합니다.
  • 올바른 상속 동작: 레코드는 두 피연산자의 런타임 형식을 확인하는 가상 메서드를 사용하여 구현 IEquatable<T> 하여 상속 계층 및 다형 시나리오에서 올바른 동작을 보장합니다.
  • 기본적으로 불변성: 레코드는 값 같음 의미 체계와 잘 작동하는 변경할 수 없는 디자인을 권장합니다.
  • 간결한 구문: 위치 매개 변수는 데이터 형식을 정의하는 컴팩트한 방법을 제공합니다.
  • 성능 향상: 컴파일러에서 생성된 같음 구현이 최적화되었으며 기본 구조체 구현과 같은 리플렉션을 사용하지 않습니다.

데이터를 저장하는 것이 기본 목표이고 값 같음 의미 체계가 필요한 경우 레코드를 사용합니다.

참조 같음을 사용하는 멤버가 있는 레코드

레코드에 참조 같음을 사용하는 멤버가 포함된 경우 레코드의 자동 값 같음 동작이 예상대로 작동하지 않습니다. 값 기반 같음을 구현하지 않는 컬렉션 System.Collections.Generic.List<T>, 배열 및 기타 참조 형식(값 같음을 구현하는 주목할 만한 예외 System.String제외)에 적용됩니다.

중요함

레코드는 기본 데이터 형식에 뛰어난 값 같음을 제공하지만 참조 같음을 사용하는 멤버에 대한 값 같음을 자동으로 해결하지는 않습니다. 레코드에 값 같음을 구현하지 않는 다른 참조 형식이 포함된 System.Collections.Generic.List<T>System.Array경우 멤버가 참조 같음을 사용하기 때문에 해당 멤버의 콘텐츠가 동일한 두 개의 레코드 인스턴스는 여전히 같지 않습니다.

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 메서드를 재정의하는 경우 값 같음 검사를 수행하는 보다 효율적인 방법을 제공하고 필요에 따라 구조체의 필드 또는 속성의 일부 하위 집합에 대한 비교를 기반으로 하는 것이 목적입니다.

==!= 연산자는 구조체에서 명시적으로 오버로드하지 않는 한 구조체에 대해 연산을 수행할 수 없습니다.

참조