CodeGym /Cursos /C# SELF /Comparación de objetos en C#: Equals y GetHashCode()

Comparación de objetos en C#: Equals y GetHashCode()

C# SELF
Nivel 34, Lección 3
Disponible

1. Introducción

En programación siempre estamos comparando cosas: números, cadenas, objetos. Pero, ¿qué significa realmente "igualdad" de objetos en C#? No siempre es tan obvio como parece. Hoy vamos a ver dos métodos clave que determinan cómo se comparan los objetos: Equals() y GetHashCode(). Entender estos métodos es súper importante para que tus programas funcionen bien, sobre todo cuando usas colecciones como Dictionary o HashSet.

En C# existen dos tipos principales de igualdad:

Igualdad de referencia (Reference Equality):

Significa que dos variables de tipo referencia apuntan al mismo objeto en memoria. Esto se comprueba con el operador == para tipos de referencia.

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

Console.WriteLine(obj1 == obj2); // false (objetos diferentes en memoria)
Console.WriteLine(obj1 == obj3); // true (ambas referencias apuntan al mismo objeto)

Igualdad de valor (Value Equality):

Significa que dos objetos diferentes (o dos tipos de valor) tienen el mismo contenido (valores de sus campos/propiedades). Esto es lo que normalmente queremos al comparar objetos. Para esto se usa el método Equals().

// Supongamos que tenemos una clase Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 y p2 son objetos diferentes, pero queremos que se consideren "iguales" por valor
Console.WriteLine(p1.Equals(p2)); // ? Depende de la implementación de Equals()
Console.WriteLine(p1.Equals(p3)); // ?

2. Método Equals()

El método Equals() está definido en la clase base System.Object, de la que heredan todos los tipos en C#. Su objetivo principal es determinar si dos objetos son iguales por valor.

Comportamiento por defecto de Equals()

Para tipos de valor (struct, int, bool, etc.): La implementación por defecto de Equals() (heredada de System.ValueType) hace una comparación bit a bit de todos los campos. Si todos los campos son iguales, los objetos se consideran iguales. Normalmente esto funciona como esperas.

Para tipos de referencia (class, string, array, etc.): La implementación por defecto de Equals() (heredada de System.Object) comprueba la igualdad de referencia. Es decir, obj1.Equals(obj2) por defecto devuelve true solo si obj1 y obj2 apuntan al mismo objeto en memoria.
class Person // Tipo de referencia
{
    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 }; // Otro objeto, pero mismo contenido

Console.WriteLine(person1.Equals(person2)); // false (por defecto compara referencias)

Como ves, el comportamiento por defecto para tipos de referencia muchas veces no es lo que queremos. Queremos que dos "Alice" con 30 años se consideren iguales, aunque sean objetos diferentes en memoria.

Sobrescribir Equals() para tus propias clases

Para conseguir igualdad por valor en tus propias clases, tienes que sobrescribir el método Equals().

Reglas para sobrescribir Equals() (contrato):

  • Reflexividad: x.Equals(x) siempre es true.
  • Simetría: Si x.Equals(y) es true, entonces y.Equals(x) también debe ser true.
  • Transitividad: Si x.Equals(y) y y.Equals(z) ambos son true, entonces x.Equals(z) también debe ser true.
  • Consistencia: Llamadas repetidas a x.Equals(y) deben dar el mismo resultado mientras los objetos no cambien.
  • Compatibilidad con null: x.Equals(null) siempre es false.

Ejemplo: Sobrescribir Equals() para la clase Person

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

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

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

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

        // 3. Convertir tipo
        Person other = (Person)obj;

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

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

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

3. Método GetHashCode()

El método GetHashCode() también está definido en System.Object. Devuelve un valor entero (hash code) que identifica el objeto de forma rápida y única (en la medida de lo posible).

¿Para qué sirve GetHashCode()?

Los hash codes se usan para optimizar el trabajo con colecciones basadas en tablas hash. Estas colecciones incluyen:

  • Dictionary<TKey, TValue> (el hash code se usa para buscar la clave rápidamente)
  • HashSet<T> (el hash code se usa para comprobar la unicidad de los elementos)
  • Hashtable

Cuando añades un objeto a un HashSet o lo usas como clave en un Dictionary, la colección primero calcula el hash code del objeto. Esto le permite "saltar" directamente a un "bucket" o grupo de elementos que tienen el mismo hash code, en vez de recorrer todos los elementos. Después, dentro de ese "bucket", se usa el método Equals() para comparar exactamente.

Reglas para sobrescribir GetHashCode() (contrato):

¡Si sobrescribes Equals(), DEBES sobrescribir también GetHashCode()! Esta es una de las reglas más importantes de C#.

  • Consistencia: Si Equals() devuelve true para dos objetos, entonces GetHashCode() para esos objetos debe devolver el mismo valor. (Lo contrario no es cierto: objetos diferentes pueden tener el mismo hash code — eso se llama "colisión").
  • Constancia: GetHashCode() debe devolver el mismo valor para el mismo objeto mientras los campos usados en la comparación no cambien.
  • Velocidad: GetHashCode() debe ser rápido y no requerir muchos cálculos.

¿Por qué es tan importante? Si sobrescribes Equals() pero no GetHashCode(), tus colecciones (especialmente las basadas en hash) funcionarán mal:

  • Dictionary no podrá encontrar tu clave.
  • HashSet añadirá duplicados porque pensará que son únicos.

Esto pasa porque por defecto GetHashCode() devuelve un hash basado en la referencia del objeto (para tipos de referencia). Si Equals() ahora compara por valor, los objetos con el mismo valor pero diferentes referencias tendrán hash codes diferentes, y la colección no los verá como iguales.

Sobrescribir GetHashCode() para la clase Person

Una buena práctica es generar el hash code usando los mismos campos que usas en Equals(). .NET tiene el método estático HashCode.Combine(), que es muy cómodo para esto.

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

    // Sobrescribir GetHashCode()
    public override int GetHashCode()
    {
        // Usamos HashCode.Combine para combinar los hashes de los campos.
        return HashCode.Combine(Name.ToLowerInvariant(), Age); 
    }
}

// Uso en una colección:
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 se considera igual a p1 por valor, no se añade Console.WriteLine($"Cantidad de personas únicas: {uniquePeople.Count}"); // Salida: 1 uniquePeople.Add(p3); Console.WriteLine($"Cantidad de personas únicas: {uniquePeople.Count}"); // Salida: 2 } } 
   
  

Punto importante: En GetHashCode() para campos string que comparas sin distinguir mayúsculas/minúsculas (StringComparison.OrdinalIgnoreCase en Equals), tienes que obtener el hash code de forma que tampoco dependa de las mayúsculas/minúsculas (por ejemplo, usando Name.ToLowerInvariant() antes de calcular el hash). Si no, Equals() devolverá true (Alice == alice), pero GetHashCode() devolverá valores diferentes, rompiendo el contrato.

4. Sobrecarga del operador == y !=

Para clases (tipos de referencia) el operador == por defecto comprueba la igualdad de referencia. Puedes sobrecargarlo para que compruebe la igualdad de valor, igual que Equals().

Reglas para sobrecargar ==:

  • Si sobrecargas ==, debes sobrecargar también !=.
  • Se recomienda que el == sobrecargado tenga el mismo comportamiento que Equals().
  • También tienes que sobrescribir GetHashCode() y Equals() al sobrecargar ==.
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    // Constructor, Equals, GetHashCode como antes

    // Sobrecarga del operador ==
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // Comprobar si left es null
        {
            return ReferenceEquals(right, null); // Si ambos son null, son iguales
        }
        return left.Equals(right); // Si no, usamos nuestro Equals() sobrescrito
    }

    // Sobrecarga del operador != (obligatorio si sobrecargas ==)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

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

Console.WriteLine(p1 == p2); // true (ahora usa el == sobrecargado)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record — igualdad automática

Desde C# 9 existe el tipo record. Es un tipo de referencia, pero implementa automáticamente la igualdad por valor (y sobrescribe Equals(), GetHashCode(), ToString() y los operadores ==/!=) usando todos sus campos/propiedades. Esto hace que record sea ideal para objetos de datos inmutables.

public record PersonRecord(string Name, int Age);

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

Console.WriteLine(r1 == r2); // true (¡comparación automática por valor!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record te hace la vida mucho más fácil cuando necesitas el comportamiento de tipo valor en un tipo de referencia.

6. Recomendaciones

¡Si sobrescribes Equals(), siempre sobrescribe también GetHashCode()! Romper esta regla lleva a comportamientos impredecibles en colecciones hash.

Equals() y GetHashCode() deben usar los mismos campos. Los campos que hacen que los objetos sean "iguales" por valor deben usarse para calcular el hash.

Ten cuidado con los tipos mutables. Si los campos usados en Equals() y GetHashCode() pueden cambiar después de crear el objeto, el hash code del objeto puede cambiar. Esto es muy malo para las colecciones hash, porque el objeto puede "perderse" después de cambiar (su hash code cambia y la colección no lo puede encontrar en su "bucket"). Para colecciones hash es mejor usar tipos inmutables como claves o elementos.

Para objetos de datos inmutables, considera usar record. Esto simplifica mucho la implementación de igualdad por valor.

Sobrecarga == y != solo para tipos de referencia cuando tenga sentido. Para tipos de valor == ya compara por valor. Si los sobrecargas, asegúrate de que el comportamiento sea igual que Equals().

2
Tarea
C# SELF, nivel 34, lección 3
Bloqueada
Uso del método `Equals` para comparar objetos
Uso del método `Equals` para comparar objetos
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION