GetHashCode и Equals: почему их переопределение критично для C#

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: та самая ошибка, которая замедляет ваш код

Каждый, кто хотя бы раз писал на C# и сталкивался с Dictionary или HashSet, рано или поздно натыкался на этот вопрос на Stack Overflow. Тысячи просмотров, десятки ответов, но суть одна: если вы переопределили Equals в своём классе, но забыли про GetHashCode — ваш код может работать неправильно или чудовищно медленно. Почему? Давайте разберёмся на реальных примерах.

Представьте, что вы пишете систему бронирования авиабилетов. У вас есть класс Flight с уникальным номером рейса и датой. Вы хотите использовать этот класс в качестве ключа в Dictionary. Кажется, что всё просто. Но без правильного GetHashCode ваш словарь может превратиться в вырожденный список со скоростью поиска O(n) вместо O(1).

Магия хеш-таблиц: как работает Dictionary в C#

Чтобы понять проблему, нужно заглянуть под капот Dictionary. Внутри это массив "корзин" (buckets). Когда вы добавляете элемент, C# вызывает метод GetHashCode() у ключа, чтобы определить номер корзины. Если в корзине уже есть элементы, вызывается Equals() для сравнения с каждым из них. Именно этот тандем обеспечивает молниеносный поиск.

Контракт, который нельзя нарушать

В C# существует негласный, но строгий контракт:

  • Если Equals() возвращает true для двух объектов, их хеш-коды обязательно должны быть равны.
  • Если хеш-коды разные, Equals() никогда не вызовется (это оптимизация).
  • Обратное не обязательно: разные объекты могут иметь одинаковый хеш-код (коллизия).

Когда вы переопределяете только Equals, но не GetHashCode, вы наследуете реализацию от System.Object, которая основана на ссылке в памяти. Два "равных" по вашей логике объекта будут иметь разные хеш-коды. И вот тут начинается ад.

Пример проблемы: рейсы, которые потерялись в словаре

Взгляните на этот код. Кажется, что он должен работать, но он не работает:

public class Flight
{
    public string FlightNumber { get; }
    public DateTime Date { get; }

    public Flight(string flightNumber, DateTime date)
    {
        FlightNumber = flightNumber;
        Date = date;
    }

    public override bool Equals(object obj)
    {
        if (obj is Flight other)
            return FlightNumber == other.FlightNumber && Date == other.Date;
        return false;
    }
    
    // GetHashCode НЕ переопределён!
}

// Использование
var flights = new Dictionary<Flight, string>();
var f1 = new Flight("SU100", new DateTime(2024, 12, 25));
var f2 = new Flight("SU100", new DateTime(2024, 12, 25));

flights[f1] = "Москва - Париж";

// Ошибка! f2 не найден, хотя f1.Equals(f2) == true
Console.WriteLine(flights.ContainsKey(f2)); // False

Почему так? f1 и f2 — разные объекты в памяти. У них разные хеш-коды от Object.GetHashCode(). Словарь помещает f1 в корзину №123, а ищет f2 в корзине №456. Equals даже не вызывается! Результат — элемент не найден.

Проблема производительности: когда всё работает, но медленно

Хуже всего — частичное решение. Допустим, вы переопределили GetHashCode, но сделали это плохо, например, вернули константу:

public override int GetHashCode() => 42; // Все объекты в одну корзину

Формально контракт соблюдён (равные объекты имеют одинаковый хеш). Но производительность рухнет: все объекты попадут в одну корзину, и Dictionary выродится в список. Вставка и поиск станут O(n). Для словаря с 10 000 элементов это катастрофа.

Как правильно переопределить GetHashCode?

Хороший GetHashCode должен быть:

  • Быстрым — вычисление не должно занимать много времени.
  • Равномерным — минимизировать коллизии.
  • Детерминированным — для одного и того же объекта всегда возвращать одно и то же значение (пока объект неизменяем).

Лучшие практики для C#

В современном C# (начиная с .NET Core 2.1 / .NET 5) появился отличный способ — HashCode.Combine:

public override int GetHashCode()
{
    return HashCode.Combine(FlightNumber, Date);
}

Для более старых версий используйте классический алгоритм с простыми числами:

public override int GetHashCode()
{
    unchecked // Позволяет избежать переполнения
    {
        int hash = 17; // Простое число
        hash = hash * 23 + FlightNumber?.GetHashCode() ?? 0;
        hash = hash * 23 + Date.GetHashCode();
        return hash;
    }
}

Обратите внимание на unchecked — он предотвращает исключения при переполнении целых чисел, что делает вычисление стабильным.

Исправленный пример: теперь всё работает

public class Flight
{
    public string FlightNumber { get; }
    public DateTime Date { get; }

    public Flight(string flightNumber, DateTime date)
    {
        FlightNumber = flightNumber;
        Date = date;
    }

    public override bool Equals(object obj)
    {
        if (obj is Flight other)
            return FlightNumber == other.FlightNumber && Date == other.Date;
        return false;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(FlightNumber, Date);
    }
}

// Теперь всё отлично!
var f1 = new Flight("SU100", new DateTime(2024, 12, 25));
var f2 = new Flight("SU100", new DateTime(2024, 12, 25));
var flights = new Dictionary<Flight, string>();
flights[f1] = "Москва - Париж";

Console.WriteLine(flights.ContainsKey(f2)); // True
Console.WriteLine(flights[f2]); // "Москва - Париж"

Особые случаи: неизменяемость и наследование

Почему класс должен быть неизменяемым?

Если вы измените поля объекта после того, как он был помещён в Dictionary, его хеш-код изменится. Словарь не узнает об этом и будет искать объект в старой корзине. Результат — потеря данных и трудноуловимые баги. Поэтому ключи в Dictionary всегда должны быть неизменяемыми. Используйте record в C# 9+ или делайте все поля только для чтения.

Наследование и GetHashCode

Если у вас есть иерархия классов, убедитесь

Блоги

Рекомендаций по книгам