Введение: та самая ошибка, которая замедляет ваш код
Каждый, кто хотя бы раз писал на 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
Если у вас есть иерархия классов, убедитесь