CodeGym /Cursos /C# SELF /Comparando objetos em C#: ...

Comparando objetos em C#: Equals e GetHashCode()

C# SELF
Nível 34 , Lição 3
Disponível

1. Introdução

Na programação a gente compara coisas o tempo todo: números, strings, objetos. Mas o que realmente significa "igualdade" de objetos em C#? Nem sempre é tão óbvio quanto parece. Hoje vamos ver dois métodos chave que definem como objetos são comparados: Equals() e GetHashCode(). Entender esses métodos é crucial pra que seu código funcione direitinho, principalmente quando você usa coleções tipo Dictionary ou HashSet.

Em C# existem dois tipos principais de igualdade:

Igualdade de referência (Reference Equality):

Significa que duas variáveis de tipo referência apontam pro mesmo objeto na memória. Isso é checado pelo operador == para tipos referência.

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;

Console.WriteLine(obj1 == obj2); // false (objetos diferentes na memória)
Console.WriteLine(obj1 == obj3); // true (ambas as referências apontam pro mesmo objeto)

Igualdade de valor (Value Equality):

Significa que dois objetos diferentes (ou dois tipos valor) têm o mesmo conteúdo (valores dos campos/propriedades). É isso que normalmente a gente quer quando compara objetos. Pra isso, usamos o método Equals().

// Suponha que temos uma classe Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 e p2 são objetos diferentes, mas queremos que sejam "iguais" em valor
Console.WriteLine(p1.Equals(p2)); // ? Depende da implementação de Equals()
Console.WriteLine(p1.Equals(p3)); // ?

2. Método Equals()

O método Equals() está definido na classe base System.Object, da qual todos os tipos em C# herdam. O objetivo principal dele é dizer se dois objetos são iguais em valor.

Comportamento padrão do Equals()

Para tipos valor (struct, int, bool etc): A implementação padrão de Equals() (herdada de System.ValueType) faz uma comparação bit a bit de todos os campos. Se todos os campos forem iguais, os objetos são considerados iguais. Normalmente funciona como esperado.

Para tipos referência (class, string, array etc): A implementação padrão de Equals() (herdada de System.Object) checa a igualdade de referência. Ou seja, obj1.Equals(obj2) por padrão só retorna true se obj1 e obj2 apontam pro mesmo objeto na memória.
class Person // Tipo referência
{
    public string Name { get; set; }
    public int Age { get; set; }
}

Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // Outro objeto, mas mesmo conteúdo

Console.WriteLine(person1.Equals(person2)); // false (por padrão compara referências)

Como você vê, o comportamento padrão pra tipos referência geralmente não é o que a gente quer! Queremos que duas "Alice" com 30 anos sejam consideradas iguais, mesmo sendo objetos diferentes na memória.

Sobrescrevendo Equals() em classes customizadas

Pra garantir igualdade por valor nas suas próprias classes, você precisa sobrescrever o método Equals().

Regras pra sobrescrever Equals() (contrato):

  • Reflexividade: x.Equals(x) sempre true.
  • Simetria: Se x.Equals(y) for true, então y.Equals(x) também tem que ser true.
  • Transitividade: Se x.Equals(y) e y.Equals(z) forem ambos true, então x.Equals(z) também tem que ser true.
  • Consistência: Várias chamadas de x.Equals(y) devem dar o mesmo resultado, enquanto os objetos não mudarem.
  • Compatibilidade com null: x.Equals(null) sempre false.

Exemplo: Sobrescrevendo Equals() na classe Person

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // Sobrescrevendo Equals()
    public override bool Equals(object? obj)
    {
        // 1. Checa null
        if (obj == null) return false;

        // 2. Checa tipo
        if (obj.GetType() != this.GetType()) return false; 

        // 3. Cast
        Person other = (Person)obj;

        // 4. Compara campos por valor
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }
}

// Usando:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);

Console.WriteLine(person1.Equals(person2)); // true (agora compara por valor!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null));    // false

3. Método GetHashCode()

O método GetHashCode() também está definido em System.Object. Ele retorna um valor inteiro (hash code) que identifica o objeto de forma rápida e (tanto quanto possível) única.

Pra que serve o GetHashCode()?

Hash codes são usados pra otimizar coleções baseadas em hash table. Exemplos:

  • Dictionary<TKey, TValue> (hash code é usado pra buscar a chave rapidinho)
  • HashSet<T> (hash code é usado pra checar se o elemento é único)
  • Hashtable

Quando você adiciona um objeto num HashSet ou usa ele como chave num Dictionary, a coleção primeiro calcula o hash code do objeto. Isso permite que ela "pule" direto pra um "balde" ou grupo de elementos com o mesmo hash code, sem precisar checar tudo. Depois, dentro desse "balde", ela usa o método Equals() pra comparar de verdade.

Regras pra sobrescrever GetHashCode() (contrato):

Se você sobrescreve Equals(), TEM QUE sobrescrever também o GetHashCode()! Essa é uma das regras mais importantes do C#.

  • Consistência: Se Equals() retorna true pra dois objetos, então GetHashCode() desses objetos tem que retornar o mesmo valor. (O contrário não é obrigatório: objetos diferentes podem ter o mesmo hash code — isso é chamado de "colisão".)
  • Constância: GetHashCode() deve retornar o mesmo valor pro mesmo objeto, enquanto os campos usados na comparação não mudarem.
  • Performance: GetHashCode() tem que ser rápido e não pode exigir muito processamento.

Por que isso é tão importante? Se você sobrescreve Equals() mas não o GetHashCode(), suas coleções (principalmente as baseadas em hash) vão funcionar errado:

  • Dictionary não vai achar sua chave.
  • HashSet vai adicionar duplicatas, achando que são únicas.

Isso acontece porque, por padrão, GetHashCode() retorna um hash baseado na referência do objeto (pra tipos referência). Se Equals() agora compara por valor, objetos com o mesmo valor mas referências diferentes vão ter hash codes diferentes, e a coleção não vai "ver" eles como iguais.

Sobrescrevendo GetHashCode() na classe Person

Boa prática: gere o hash code usando os mesmos campos que você usa no Equals(). O .NET tem o método estático HashCode.Combine(), que é bem prático pra isso.

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public override bool Equals(object? obj)
    {
        if (obj == null || obj.GetType() != this.GetType()) return false;
        Person other = (Person)obj;
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }

    // Sobrescrevendo GetHashCode()
    public override int GetHashCode()
    {
        // Usa HashCode.Combine pra juntar os hashes dos campos.
        return HashCode.Combine(Name.ToLowerInvariant(), Age); 
    }
}

// Usando em coleção:
public class Program
{
    public static void Main(string[] args)
    {
        Person p1 = new Person("Alice", 30);
        Person p2 = new Person("Alice", 30);
        Person p3 = new Person("Bob", 25);

        HashSet
  
    uniquePeople = new HashSet
   
    (); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 é igual a p1 em valor, não vai ser adicionado Console.WriteLine($"Quantidade de pessoas únicas: {uniquePeople.Count}"); // Saída: 1 uniquePeople.Add(p3); Console.WriteLine($"Quantidade de pessoas únicas: {uniquePeople.Count}"); // Saída: 2 } } 
   
  

Ponto importante: No GetHashCode() pra campos string que são comparados sem diferenciar maiúsculas/minúsculas (StringComparison.OrdinalIgnoreCase no Equals), você tem que gerar o hash code de um jeito que também não dependa de maiúsculas/minúsculas (tipo usando Name.ToLowerInvariant() antes de calcular o hash). Senão, Equals() vai retornar true (Alice == alice), mas GetHashCode() vai dar valores diferentes, quebrando o contrato.

4. Sobrecarga dos operadores == e !=

Pra classes (tipos referência), o operador == por padrão checa igualdade de referência. Você pode sobrecarregar ele pra checar igualdade de valor, igual ao Equals().

Regras pra sobrecarregar ==:

  • Se você sobrecarrega ==, tem que sobrecarregar também o !=.
  • É recomendado que o == sobrecarregado tenha o mesmo comportamento do Equals().
  • Também é preciso sobrescrever GetHashCode() e Equals() quando sobrecarregar ==.
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    // Construtor, Equals, GetHashCode como antes

    // Sobrecarga do operador ==
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // Checa se left é null
        {
            return ReferenceEquals(right, null); // Se ambos são null, são iguais
        }
        return left.Equals(right); // Senão, usa nosso Equals() sobrescrito
    }

    // Sobrecarga do operador != (obrigatório se sobrecarregar ==)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

// Usando:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;

Console.WriteLine(p1 == p2); // true (agora usa o == sobrecarregado)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record — igualdade automática

A partir do C# 9, surgiu o tipo record. Ele é um tipo referência, mas implementa automaticamente igualdade por valor (e sobrescreve Equals(), GetHashCode(), ToString() e os operadores ==/!=) baseado em todos os seus campos/propriedades. Isso faz do record perfeito pra objetos de dados imutáveis.

public record PersonRecord(string Name, int Age);

// Usando:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);

Console.WriteLine(r1 == r2); // true (comparação automática por valor!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record facilita muito a vida quando você quer comportamento de tipo valor pra um tipo referência.

6. Recomendações

Se você sobrescreve Equals(), sempre sobrescreva também o GetHashCode()! Ignorar isso causa comportamento imprevisível em coleções baseadas em hash.

Equals() e GetHashCode() devem usar os mesmos campos. Os campos que fazem os objetos "iguais" em valor devem ser usados pra calcular o hash.

Cuidado com tipos mutáveis. Se os campos usados no Equals() e GetHashCode() podem mudar depois que o objeto é criado, o hash code do objeto pode mudar. Isso é péssimo pra coleções baseadas em hash, porque o objeto pode "sumir" depois de mudar (o hash code muda e a coleção não acha mais ele no "balde" certo). Pra coleções baseadas em hash, prefira tipos imutáveis como chave ou elemento.

Pra objetos de dados imutáveis, considere usar record. Isso facilita muito a implementação de igualdade por valor.

Só sobrecarregue == e != pra tipos referência quando fizer sentido. Pra tipos valor, == já compara por valor. Se for sobrecarregar, garanta que o comportamento bate com o Equals().

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION