Compartilhar via


Como definir a igualdade de valor para uma classe ou struct (Guia de programação em C#)

Dica

Considere usar registros primeiro. Os registros implementam automaticamente a igualdade de valor com código mínimo, tornando-os a abordagem recomendada para a maioria dos tipos focados em dados. Se você precisar de lógica de igualdade de valor personalizada ou não puder usar registros, continue com as etapas manuais de implementação abaixo.

Quando você define uma classe ou struct, decide se faz sentido criar uma definição personalizada de igualdade de valor (ou equivalência) para o tipo. Normalmente, você implementa igualdade de valor quando espera adicionar objetos do tipo a uma coleção, ou quando seu objetivo principal for armazenar um conjunto de campos ou propriedades. Você pode basear sua definição de igualdade de valor em uma comparação de todos os campos e propriedades no tipo ou pode basear a definição em um subconjunto.

Em ambos os casos e em classes e structs, sua implementação deve seguir as cinco garantias de equivalência (para as seguintes regras, suponha que x, y e z não sejam nulos):

  1. Propriedade reflexiva: x.Equals(x) retorna true.

  2. Propriedade simétrica: x.Equals(y) retorna o mesmo valor que y.Equals(x).

  3. Propriedade transitiva: se (x.Equals(y) && y.Equals(z)) retorna true, então x.Equals(z) retorna true.

  4. Invocações sucessivas de x.Equals(y) retornam o mesmo valor, contanto que os objetos referenciados por x e y não sejam modificados.

  5. Qualquer valor não nulo não é igual a nulo. No entanto, x.Equals(y) gera uma exceção quando x é nulo. Isso quebra as regras 1 ou 2, a depender do argumento para Equals.

Qualquer struct que você define já tem uma implementação padrão de igualdade de valor que ele herda da substituição System.ValueType do método Object.Equals(Object). Essa implementação usa a reflexão para examinar todos os campos e propriedades no tipo. Embora essa implementação produza resultados corretos, ela é relativamente lenta em comparação com uma implementação personalizada escrita especificamente para o tipo.

Os detalhes de implementação para a igualdade de valor são diferentes para classes e struct. No entanto, as classes e structs exigem as mesmas etapas básicas para implementar a igualdade:

  1. Substitua o método virtualObject.Equals(Object). Isso fornece um comportamento de igualdade polimórfica, permitindo que seus objetos sejam comparados corretamente quando tratados como object referências. Ele garante o comportamento adequado em coleções e ao usar o polimorfismo. Na maioria dos casos, sua implementação de bool Equals( object obj ) deve apenas chamar o método Equals específico do tipo que é a implementação da interface System.IEquatable<T>. (Consulte a etapa 2.)

  2. Implemente a interface System.IEquatable<T> fornecendo um método Equals específico do tipo. Isso fornece verificação de igualdade com segurança de tipo sem boxe, resultando em melhor desempenho. Ele também evita a conversão desnecessária e habilita a verificação de tipo de tempo de compilação. Isso é o local em que a comparação de equivalência de fato é realizada. Por exemplo, você pode decidir definir a igualdade comparando apenas um ou dois campos em seu tipo. Não lance exceções a partir de Equals. Para classes relacionadas por herança:

    • esse método deve examinar somente os campos que são declarados na classe. Ele deve chamar base.Equals para examinar os campos que estão na classe base. (Não chame base.Equals se o tipo herdar diretamente de Object, pois a implementação Object de Object.Equals(Object) executa uma verificação de igualdade de referência.)

    • Duas variáveis devem ser consideradas iguais somente se os tipos de tempo de execução das variáveis que estão sendo comparadas forem os mesmos. Além disso, verifique se a implementação IEquatable do método Equals para o tipo de tempo de execução será usada se os tipos de tempo de execução e tempo de compilação de uma variável forem diferentes. Uma estratégia para garantir que os tipos de tempo de execução sejam sempre comparados corretamente é implementar IEquatable somente em classes sealed. Para obter mais informações, consulte o exemplo de classe mais adiante neste artigo.

  3. Opcional, mas recomendado: sobrecarregue os operadores == e !=. Isso fornece sintaxe consistente e intuitiva para comparações de igualdade, correspondendo às expectativas do usuário de tipos internos. Ele garante isso obj1 == obj2 e obj1.Equals(obj2) se comporta da mesma maneira.

  4. Substitua Object.GetHashCode para que os dois objetos que têm a igualdade de valor produzam o mesmo código hash. Isso é necessário para o comportamento correto em coleções baseadas em hash como Dictionary<TKey,TValue> e HashSet<T>. Objetos iguais devem ter códigos de hash iguais ou essas coleções não funcionarão corretamente.

  5. Opcional: para dar suporte às definições para “maior que” ou “menor que”, implemente a interface IComparable<T> para seu tipo e também sobrecarregue os operadores <= e >=. Isso permite operações de classificação e fornece uma relação de ordenação completa para seu tipo, útil ao adicionar objetos a coleções classificadas ou ao classificar matrizes ou listas.

Exemplo de registro

O exemplo a seguir mostra como os registros implementam automaticamente a igualdade de valor com código mínimo. O primeiro registro TwoDPoint é um tipo de registro simples que implementa automaticamente a igualdade de valor. O segundo registro ThreeDPoint demonstra que os registros podem ser derivados de outros registros e ainda manter o comportamento adequado de igualdade de valor:

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

Os registros fornecem várias vantagens para a igualdade de valor:

  • Implementação automática: os registros implementam System.IEquatable<T> e substituem Object.Equalsautomaticamente, Object.GetHashCodee os ==/!= operadores.
  • Comportamento correto de herança: os registros são implementados IEquatable<T> usando métodos virtuais que verificam o tipo de runtime de ambos os operandos, garantindo o comportamento correto em hierarquias de herança e cenários polimórficos.
  • Imutabilidade por padrão: os registros incentivam o design imutável, que funciona bem com semântica de igualdade de valor.
  • Sintaxe concisa: os parâmetros posicionais fornecem uma maneira compacta de definir tipos de dados.
  • Melhor desempenho: a implementação de igualdade gerada pelo compilador é otimizada e não usa reflexão como a implementação de struct padrão.

Use registros quando sua meta principal é armazenar dados e você precisa de semântica de igualdade de valor.

Registros com membros que usam a igualdade de referência

Quando os registros contêm membros que usam a igualdade de referência, o comportamento de igualdade de valor automático dos registros não funciona conforme o esperado. Isso se aplica a coleções como System.Collections.Generic.List<T>, matrizes e outros tipos de referência que não implementam igualdade baseada em valor (com a exceção notável de , que implementa a igualdade de System.Stringvalor).

Importante

Embora os registros forneçam excelente igualdade de valor para tipos de dados básicos, eles não resolvem automaticamente a igualdade de valor para membros que usam igualdade de referência. Se um registro contiver um System.Collections.Generic.List<T>, System.Arrayou outros tipos de referência que não implementam a igualdade de valor, duas instâncias de registro com conteúdo idêntico nesses membros ainda não serão iguais porque os membros usam igualdade de referência.

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!

Isso ocorre porque os registros usam o Object.Equals método de cada membro, e os tipos de coleção normalmente usam igualdade de referência em vez de comparar seu conteúdo.

O seguinte mostra o problema:

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

Veja como isso se comporta quando você executa o código:

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

Soluções para registros com membros de igualdade de referência

  • Implementação personalizadaSystem.IEquatable<T>: substitua a igualdade gerada pelo compilador por uma versão codificada à mão que fornece comparação baseada em conteúdo para membros de igualdade de referência. Para coleções, implemente a comparação elemento por elemento usando Enumerable.SequenceEqual ou métodos semelhantes.

  • Use tipos de valor sempre que possível: considere se seus dados podem ser representados com tipos de valor ou estruturas imutáveis que naturalmente dão suporte à igualdade de valor, como System.Numerics.Vector<T> ou Plane.

  • Use tipos com igualdade baseada em valor: para coleções, considere usar tipos que implementam igualdade baseada em valor ou implementem tipos de coleção personalizados que substituem Object.Equals para fornecer comparação baseada em conteúdo, como System.Collections.Immutable.ImmutableArray<T> ou System.Collections.Immutable.ImmutableList<T>.

  • Design com igualdade de referência em mente: aceite que alguns membros usarão a igualdade de referência e projetarão sua lógica de aplicativo adequadamente, garantindo que você reutilize as mesmas instâncias quando a igualdade for importante.

Aqui está um exemplo de implementação da igualdade personalizada para registros com coleções:

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

Essa implementação personalizada funciona corretamente:

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

O mesmo problema afeta matrizes e outros tipos de coleção:

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

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

As matrizes também usam a igualdade de referência, produzindo os mesmos resultados inesperados:

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

Até mesmo coleções readonly exibem esse comportamento de igualdade de referência:

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

A visão chave é que os registros resolvem o problema de igualdade estrutural , mas não alteram o comportamento de igualdade semântica dos tipos que contêm.

Exemplo de classe

O exemplo a seguir mostra como implementar a igualdade de valor em uma classe (tipo de referência). Essa abordagem manual é necessária quando você não pode usar registros ou precisa de lógica de igualdade personalizada:

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

Em classes (tipo de referência), a implementação padrão de ambos os métodos Object.Equals(Object) executa uma comparação de igualdade de referência, não uma verificação de igualdade de valor. Quando um implementador substitui o método virtual, o objetivo é fornecer semântica de igualdade de valor.

Os operadores == e != podem ser usados com classes, mesmo se a classe não sobrecarregá-los. No entanto, o comportamento padrão é executar uma verificação de igualdade de referência. Em uma classe, se você sobrecarregar o método Equals, você deverá sobrecarregar os operadores == e !=, mas isso não é necessário.

Importante

O código de exemplo anterior pode não lidar com todos os cenários de herança da maneira esperada. Considere o seguinte código:

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

Esse código relata que p1 é igual a p2 apesar da diferença de valores z. A diferença é ignorada porque o compilador escolhe a implementação TwoDPoint de IEquatable com base no tipo de tempo de compilação. Esta é uma questão fundamental com a igualdade polimórfica nas hierarquias de herança.

Igualdade polimórfica

Ao implementar a igualdade de valor em hierarquias de herança com classes, a abordagem padrão mostrada no exemplo de classe pode levar a um comportamento incorreto quando os objetos são usados polimorficamente. O problema ocorre porque System.IEquatable<T> as implementações são escolhidas com base no tipo de tempo de compilação, não no tipo de runtime.

O problema com implementações padrão

Considere este cenário problemático:

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!

A comparação retorna True porque o compilador seleciona TwoDPoint.Equals(TwoDPoint) com base no tipo declarado, ignorando as diferenças de Z coordenadas.

A chave para corrigir a igualdade polimórfica é garantir que todas as comparações de igualdade usem o método virtual Object.Equals , que pode verificar os tipos de runtime e manipular a herança corretamente. Isso pode ser feito usando a implementação de interface explícita para System.IEquatable<T> que ele delega ao método virtual:

A classe base demonstra os padrões de chave:

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

A classe derivada estende corretamente a lógica de igualdade:

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

Veja como essa implementação lida com os cenários polimórficos problemáticos:

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

A implementação também lida corretamente com comparações de tipo direto:

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

A implementação de igualdade também funciona corretamente com coleções:

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

O código anterior demonstra elementos-chave para implementar a igualdade baseada em valor:

  • Substituição virtualEquals(object?): a lógica de igualdade principal ocorre no método virtualObject.Equals, que é chamado independentemente do tipo de tempo de compilação.
  • Verificação de tipo de runtime: o uso this.GetType() != p.GetType() garante que objetos de diferentes tipos nunca sejam considerados iguais.
  • Implementação explícita da interface: a System.IEquatable<T> implementação delega ao método virtual, impedindo problemas de seleção de tipo de tempo de compilação.
  • Método auxiliar virtual protegido: o protected virtual Equals(TwoDPoint? p) método permite que classes derivadas substituam a lógica de igualdade, mantendo a segurança do tipo.

Use esse padrão quando:

  • Você tem hierarquias de herança em que a igualdade de valor é importante
  • Objetos podem ser usados polimorficamente (declarados como tipo base, instanciados como tipo derivado)
  • Você precisa de tipos de referência com semântica de igualdade de valor

A abordagem preferencial é usar record tipos para implementar a igualdade baseada em valor. Essa abordagem requer uma implementação mais complexa do que a abordagem padrão e requer um teste completo de cenários polimórficos para garantir a correção.

Exemplo de struct

O exemplo a seguir mostra como implementar a igualdade de valor em um struct (tipo de valor). Embora os structs tenham igualdade de valor padrão, uma implementação personalizada pode melhorar o desempenho:

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

Para estruturas, a implementação padrão de Object.Equals(Object) (que é a versão substituída em System.ValueType) executa uma verificação de igualdade de valor por meio de reflexão para comparar os valores de cada campo no tipo. Embora essa implementação produza resultados corretos, ela é relativamente lenta em comparação com uma implementação personalizada escrita especificamente para o tipo.

Quando você substitui o método virtual Equals em um struct, a finalidade é fornecer um meio mais eficiente de executar a verificação de igualdade de valor e, opcionalmente, basear a comparação em algum subconjunto dos campos ou propriedades do struct.

Os operadores == e != não podem operar em um struct a menos que o struct os sobrecarregue explicitamente.

Confira também