== , Equals и GetHashCode
Шесть строк вывода, из которых пятая ломает большинству кандидатов картину мира.
Правило, которое надо запомнить: оператор
==выбирается компилятором по статическому типу переменных, аEquals— виртуальный и выбирается по фактическому типу объекта в рантайме.
Смотрим код. Что выведет?
using System;
class Program
{
static void Main()
{
string a = "hello";
string b = "hello";
string c = new string(new char[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(a == b); // 1
Console.WriteLine(a == c); // 2
Console.WriteLine(ReferenceEquals(a, b)); // 3
Console.WriteLine(ReferenceEquals(a, c)); // 4
object oa = a;
object oc = c;
Console.WriteLine(oa == oc); // 5
Console.WriteLine(oa.Equals(oc)); // 6
}
}Результат:
True
True
True
False
False
TrueРазбор по строкам
1–2. У string оператор == перегружен и сравнивает содержимое. Поэтому обе строки истинны, даже когда объекты разные.
3. True — и это интернирование: одинаковые строковые литералы компилятор кладёт в общий пул, и a с b — буквально одна и та же ссылка.
4. False — строка, собранная в рантайме через new string(...), в пул не попадает. Это другой объект с тем же содержимым.
5. Вот он, главный сюрприз: False. Мы положили те же самые строки в переменные типа object. Компилятор смотрит на статические типы операндов — оба object, — перегрузки == для object нет, и он подставляет сравнение ссылок. Содержимое никого не интересует.
6. True: Equals — виртуальный метод. Реальный объект — строка, значит вызовется String.Equals, а он сравнивает символы.
Именно поэтому в обобщённом коде (T, object, рефлексия) сравнивать через == опасно. Там используют EqualityComparer<T>.Default.Equals(x, y) — он сам разберётся, что вызывать.
| Способ | Как выбирается | Что сравнивает |
== | компилятор, по статическому типу | ссылки — если оператор не перегружен |
Equals(object) | рантайм, виртуально | то, что задал автор типа |
ReferenceEquals(a, b) | всегда одно и то же | только ссылки, обмануть нельзя |
EqualityComparer<T>.Default | рантайм, по типу T | безопасный выбор для generic-кода |
Контракт Equals и GetHashCode
Второй классический вопрос: «Переопределили Equals, а GetHashCode не тронули. Что сломается?» Ответ — хеш-таблицы. Запустите:
using System;
using System.Collections.Generic;
class Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj) => obj is Point p && p.X == X && p.Y == Y;
// GetHashCode переопределить забыли
}
class Program
{
static void Main()
{
Dictionary<Point, string> map = new Dictionary<Point, string>();
map[new Point(1, 2)] = "значение";
Point key = new Point(1, 2);
Console.WriteLine($"Equals говорит, что ключи равны: {key.Equals(new Point(1, 2))}");
Console.WriteLine($"А словарь ключ находит: {map.ContainsKey(key)}");
Console.WriteLine($"Записей в словаре: {map.Count}");
}
}Результат:
Equals говорит, что ключи равны: True
А словарь ключ находит: False
Записей в словаре: 1Объекты равны — но словарь их не находит. Значение лежит в словаре и стало недостижимым: типичный источник «фантомных» багов и медленной утечки.
Как это работает под капотом
Dictionary<TKey, TValue> — это массив «корзин» (buckets). Чтобы найти ключ, он:
- берёт
GetHashCode()ключа; - по остатку от деления на число корзин определяет корзину;
- внутри корзины идёт по цепочке и сравнивает сначала сохранённый хеш, а уже потом
Equals.
Если GetHashCode не переопределён, он унаследован от object и по сути привязан к экземпляру: два разных объекта дают разные числа. Значит наш ключ ищется вообще в другой корзине — до сравнения через Equals дело просто не доходит. Отсюда контракт:
- Равные объекты обязаны иметь равные хеш-коды. Это жёсткое требование.
- Обратное неверно: одинаковый хеш у неравных объектов — законная коллизия, её разрулит
Equals. Equalsдолжен быть рефлексивным (x.Equals(x)—true), симметричным, транзитивным и не бросать исключений;x.Equals(null)— всегдаfalse.- Пока объект лежит в хеш-таблице, его хеш-код не должен меняться.
Последний пункт — отдельная ловушка. Чиним GetHashCode, но берём изменяемые поля:
using System;
using System.Collections.Generic;
class Point : IEquatable<Point>
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public bool Equals(Point other) => other != null && other.X == X && other.Y == Y;
public override bool Equals(object obj) => Equals(obj as Point);
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = hash * 31 + X;
hash = hash * 31 + Y;
return hash;
}
}
}
class Program
{
static void Main()
{
Dictionary<Point, string> map = new Dictionary<Point, string>();
Point key = new Point(1, 2);
map[key] = "значение";
Console.WriteLine($"Новый эквивалентный ключ находится: {map.ContainsKey(new Point(1, 2))}");
key.X = 42; // мутируем поле, участвующее в хеше
Console.WriteLine($"Тот же самый объект-ключ находится: {map.ContainsKey(key)}");
Console.WriteLine($"Записей в словаре по-прежнему: {map.Count}");
}
}Результат:
Новый эквивалентный ключ находится: True
Тот же самый объект-ключ находится: False
Записей в словаре по-прежнему: 1Теперь контракт соблюдён, и словарь честно находит эквивалентный ключ. Но стоило изменить X — и запись «потерялась»: она лежит в корзине по старому хешу, а ищем мы по новому. Вывод: ключ хеш-таблицы должен быть неизменяемым. Поля — readonly, свойства — только get.
Реализация IEquatable<Point> — не украшение: без неё Dictionary вызывает Equals(object), а это упаковка (boxing) для структур и лишняя проверка типа. С ней вызов типизированный и быстрый.
Современный способ: record
Начиная с C# 9 всю эту рутину пишет компилятор:
public record Point(int X, int Y);
// Компилятор сам сгенерирует Equals, GetHashCode, ==, != и ToString().
// Свойства получаются init-only — значит ключ неизменяемый по построению.
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True — у record оператор == сравнивает по значениюА вручную удобнее считать хеш через HashCode.Combine(X, Y) — он и качественнее «магии 17/31», и короче.
Частые ошибки на собеседовании
- «
==для ссылочных типов всегда сравнивает ссылки». Не всегда:string,record,Uri,Versionи любой тип с перегрузкой оператора сравнивают по значению. - «
==для строк — это про ссылки, потому что есть интернирование». Интернирование — отдельная история; оператор перегружен и сравнивает символы независимо от него. - Возвращать из
GetHashCode()константу «чтобы соблюсти контракт». Формально законно, но все ключи попадут в одну корзину, иDictionaryвыродится в линейный список:O(n)вместоO(1). - Забывать про структуры. У
structестьEquals/GetHashCode«из коробки», но реализованы они через рефлексию — медленно и с боксингом. Для структур-ключей переопределяйте их явно (или беритеreadonly record struct). - Изменяемый ключ. Мутация поля, участвующего в хеше, «теряет» запись в словаре — мы это только что видели.
Итоги-шпаргалка
==связывается статически (по типу переменной),Equals— виртуально (по типу объекта). Отсюда весь сюр сobject oa = "hello".- Одинаковые литералы интернируются и физически являются одной ссылкой;
new string(...)— нет. - В generic-коде сравнивайте через
EqualityComparer<T>.Default, а не через==. - Контракт: равные объекты → равные хеш-коды. Нарушили —
DictionaryиHashSetперестают находить ключи, хотяEqualsвозвращаетtrue. - Переопределяете
Equals— переопределяйтеGetHashCode. Всегда. И реализуйтеIEquatable<T>, чтобы избежать боксинга. - Ключ хеш-таблицы должен быть неизменяемым. Проще всего —
record.