== , 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). Чтобы найти ключ, он:

  1. берёт GetHashCode() ключа;
  2. по остатку от деления на число корзин определяет корзину;
  3. внутри корзины идёт по цепочке и сравнивает сначала сохранённый хеш, а уже потом 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.
Проверьте себя
1. Переменные oa и oc имеют статический тип object и ссылаются на две РАЗНЫЕ строки с одинаковым содержимым "hello". Что выведет Console.WriteLine(oa == oc)?
ATrue — строки в C# всегда сравниваются по содержимому
BFalse — для операндов типа object компилятор подставляет сравнение по ссылке
CОшибку компиляции: object не поддерживает ==
DTrue, потому что все строки интернируются
2. Класс переопределил Equals, но не переопределил GetHashCode. Что сломается в первую очередь?
AОператор == перестанет компилироваться
BОбъекты перестанут находиться как ключи в Dictionary и элементы в HashSet, хотя Equals возвращает true
CПрограмма упадёт с NullReferenceException при первом же сравнении
DСборщик мусора перестанет собирать такие объекты