Equals, GetHashCode, сравнение объектов

↑ 1.3 C♯: основы языка · ← Предыдущая

Обязательно~6 мин чтенияУровень: junior

Зачем это на собесе

контракт Equals/GetHashCode — любимый вопрос: «что будет, если переопределить Equals, но не GetHashCode». От этого зависит работа Dictionary, HashSet, LINQ Distinct и сравнений сущностей.

Объяснение

Виды равенства

  • Ссылочное — один и тот же объект: ReferenceEquals(a, b). По умолчанию для классов.
  • По значению — одинаковые данные. По умолчанию для struct (через рефлексию, медленно), для record — сгенерировано компилятором.
  • == для классов по умолчанию ссылочное, но может быть перегружено (string, record).

Контракт Equals

Рефлексивность (a.Equals(a)), симметричность, транзитивность, согласованность (одинаковый результат при повторах), a.Equals(null) == false.

Контракт GetHashCode

  • Если a.Equals(b), то a.GetHashCode() == b.GetHashCode() — обязательно.
  • Обратное не требуется: одинаковый хэш ≠ равенство (коллизии допустимы).
  • Хэш не должен меняться, пока объект лежит в хэш-коллекции.

Почему это важно: как работает Dictionary

  1. По GetHashCode() ключа вычисляется «корзина» (bucket).
  2. Внутри корзины элементы сравниваются через Equals. Если Equals переопределён, а GetHashCode нет — равные объекты попадут в разные корзины, и dict.ContainsKey(equalKey) вернёт false.

Правильная реализация

public sealed class Snils : IEquatable<Snils>
{
    public string Value { get; }
    public Snils(string value) => Value = Normalize(value);
 
    public bool Equals(Snils? other) => other is not null && Value == other.Value;
    public override bool Equals(object? obj) => obj is Snils s && Equals(s);
    public override int GetHashCode() => Value.GetHashCode();          // по тем же полям, что Equals
    public static bool operator ==(Snils? a, Snils? b) => Equals(a, b);
    public static bool operator !=(Snils? a, Snils? b) => !Equals(a, b);
}
 
// для нескольких полей
public override int GetHashCode() => HashCode.Combine(System, Code, Version);

Проще всего — record/record struct: всё генерирует компилятор.

public sealed record CodingKey(string System, string Code, string? Version);  // ключ справочника терминологии
var cache = new Dictionary<CodingKey, Concept>();

IEqualityComparer — равенство «снаружи»

Когда нельзя или не нужно менять тип:

var byCode = new Dictionary<string, Concept>(StringComparer.OrdinalIgnoreCase);
var unique = patients.DistinctBy(p => p.Snils);        // .NET 6+
public sealed class PatientBySnilsComparer : IEqualityComparer<Patient>
{
    public bool Equals(Patient? x, Patient? y) => x?.Snils == y?.Snils;
    public int GetHashCode(Patient p) => p.Snils.GetHashCode();
}

Сущности (Entity) vs Value Objects

  • Entity — равенство по идентификатору (Id), данные могут меняться.
  • Value object — равенство по всем значениям, иммутабелен (деньги, адрес, код справочника).

Нюансы и подводные камни

  • Изменяемые поля в хэше: положили объект в HashSet, изменили поле, участвующее в GetHashCode, — объект «потерялся» (Contains вернёт false).
  • GetHashCode строки в .NET Core рандомизирован между запусками процесса: не сохраняйте его в БД и не используйте для шардирования. Для стабильного хэша — XxHash64 или SHA.
  • Переопределение == без Equals (и наоборот) приводит к непоследовательному поведению.
  • EF Core сравнивает сущности по ключу через change tracker. Переопределённый Equals у сущностей может мешать, делайте это аккуратно.
  • Struct без переопределения Equals сравнивается рефлексией и боксится — медленно в словарях.

Тестирование

[Fact]
public void Equal_objects_have_equal_hash_codes()
{
    var a = new CodingKey("icd10", "J06.9", null);
    var b = new CodingKey("icd10", "J06.9", null);
    a.Should().Be(b);
    a.GetHashCode().Should().Be(b.GetHashCode());
    new HashSet<CodingKey> { a }.Contains(b).Should().BeTrue();
}

Вопросы с ответами

Связанные темы