Как стать автором
Обновить
588.3
OTUS
Цифровые навыки от ведущих экспертов

Почему == и .Equals() — не одно и то же (и как это вас подставит)

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров18K

Привет, Хабр! Когда речь заходит о сравнении объектов, все почему‑то решают, что это элементарный вопрос: ну есть же == и есть .Equals(), в чём проблема? На практике — проблема порой вырастает в целое шапито. Сегодня поговорим о весьма противоречивой парочке. Почему иногда, написав var a = b; if (a == b) { ... }, мы проверяем одно, а вызвав a.Equals(b) — совершенно другое? И главное: как это может довольно жестоко подставить нас в реальном коде, когда «ой, ну мы же не ожидали, что кто‑то переопределит оператор == так хитро».

Момент первый: разные семантики, разные источники правды

==: оператор, который может (и часто хочет) быть переопределён

В C# (и не только в C#) оператор == по умолчанию для ссылочных типов (reference types) проверяет, указывают ли две переменные на один и тот же объект в памяти. Другими словами, это сравнение ссылок. Положили мы var person1 = new Person("Ivan"); var person2 = new Person("Ivan"); — и person1 == person2 вернёт false, потому что в куче лежат два разных объекта, пусть даже с одинаковыми полями.

Но! Никто не мешает в своём классе перегрузить оператор ==. Тогда он уже не сравнивает ссылки, а делает что‑то ещё. Например, часто в структурах или классах‑обёртках для значений (типа Money) мы хотим, чтобы == сравнивал значение, а не ссылку. Или сравнение строк: string в.NET перегружает оператор ==, и там он уже проверяет содержимое строк. То есть, когда мы делаем:

string s1 = "Hello";
string s2 = String.Intern("Hello");
Console.WriteLine(s1 == s2); // True

Для string при == срабатывает их собственная реализация, проверяющая посимвольно идентичность. Разные экземпляры, но результат true. Аналогично, если конкатенировать, var s3 = "Hel" + "lo", то компилятор может оптимизировать это, интернировать строку, и магии хоть отбавляй. Но главное, что == у string сравнивает по значению, а не по ссылке (т. е. если бы не был перегружен, он всегда бы смотрел, указывают ли s1 и s2 на один участок памяти).

Короче, оператор == штука гибкая: в случае встроенных типов (int, double, bool и прочих) он сравнивает значение, в случае string — тоже значение, в случае любого другого класса по умолчанию — ссылку (но при желании можно переопределить).

.Equals(): метод, который идёт от System.Object

Каждый объект в.NET наследует метод public virtual bool Equals(object obj). Изначально этот метод в базовом классе object тоже сравнивает ссылки (для ссылочных типов). Но в конкретных классах его можно (и часто нужно) переопределить так, чтобы сравнивать содержимое. Структуры (например, System.Int32) переопределяют его, сравнивая значимые поля. Класс string, разумеется, переопределяет .Equals(), сравнивая посимвольно.

Обычно, если класс «имеет смысл» сравнивать по значению (например, Point(x, y), Money(amount, currency) и т. д.), хорошим тоном считается переопределить .Equals() и заодно (обязательно!) .GetHashCode(). Тогда ваше сравнение будет работать адекватно, а коллекции (типа Dictionary или HashSet) не сойдут с ума от нестыковок.

Но вот парадокс: можно переопределить лишь метод .Equals() и при этом не перегружать оператор ==. И тогда == будет продолжать сравнивать ссылки, а .Equals() — значения. Представьте класс:

public class Person
{
    public string Name { get; }
    public int Age { get; }
    
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // Переопределяем Equals
    public override bool Equals(object? obj)
    {
        if (obj is Person other)
        {
            return Name == other.Name && Age == other.Age;
        }
        return false;
    }

    // При переопределении Equals нужно всегда переопределять GetHashCode
    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Age);
    }
}

Тут мы честно определили логику равенства по имени и возрасту. Но оператор == не трогали. Значит, если у нас есть:

var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);

Console.WriteLine(p1.Equals(p2));   // True
Console.WriteLine(p1 == p2);       // False, ведь мы не перегрузили operator==

На уровне Equals() всё ок, люди‑то как бы одинаковы, а на уровне == — нет, разные объекты в памяти. И, как назло, кому‑то может прийти в голову в коде где‑нибудь сказать if (p1 == p2), рассчитывая, что это «эквивалентно» p1.Equals(p2). Получаем скрытые баги.

Момент второй: когда это больно?

1. При работе со строками

Как упоминалось, строки в C# перегружают ==, поэтому:

string a = "Test";
string b = "T" + "est"; // Компилятор (или интернирование) это может схлопнуть
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True

Здесь никаких сюрпризов. Но представим, что есть какая‑то своя обёртка над строками, которая не перегружает оператор ==, зато переопределяет .Equals(). И где‑то в коде мы не глядя используем ==, а потом удивляемся, почему результаты расходятся.

2. В структурах (ValueType)

Структуры в.NET по умолчанию сравниваются по значению (даже без переопределения Equals, там есть реализация в базовом System.ValueType). Но оператор == у структуры не всегда доступен. Например, у int, double, bool и т. д. он, конечно, есть (встроенный), а вот у своей кастомной структуры он не появится, если вы его явно не объявите. В результате, если вы написали свой struct, у вас может не быть == (причём компилятор даже ругается при попытке использовать ==, если он не определён).

Где тут подвох? В том, что некоторым кажется, что раз это структура, то == у неё автоматически «работает как у int». Но нет. Нужно не забыть:

public struct Money : IEquatable<Money>
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public bool Equals(Money other)
        => Amount == other.Amount && Currency == other.Currency;

    public override bool Equals(object? obj)
        => obj is Money m && Equals(m);

    public override int GetHashCode()
        => HashCode.Combine(Amount, Currency);

    // Перегружаем оператор
    public static bool operator ==(Money left, Money right) 
        => left.Equals(right);

    public static bool operator !=(Money left, Money right) 
        => !(left == right);
}

Теперь == и Equals() ведут себя согласованно. Без этих перегрузок == не станет сравнивать поля структуры по значению (кроме встроенных типов).

3. Когда == у класса “незаметно” перегружен библиотекой

Иногда мы берём какую‑нибудь внешнюю библиотеку, где класс кажется обычным. Но авторы библиотеки перегрузили ==. Тогда не подозревая можно написать myObject1 == myObject2. В голове: «Я же сравнил ссылки, чего мне переживать?» — а на деле код выполняет логику сравнения полей (или ещё чего‑то). Не углубимся — получаем загадочные расхождения, баги и недоумение.

4. В linq-to-sql и ORM-ках

Вместо физического оператора == или Equals может использоваться какая‑то Expression‑магия, которая транслируется в SQL. Но по ошибке мы запихиваем туда неподходящий оператор или .Equals(), и SQL‑запрос генерируется со своей логикой. Где‑то оно работает, где‑то — ломается. Порой == и .Equals() в LINQ‑выражениях трактуются чуть по‑разному при генерации SQL (особенно в сложных сценариях или при работе с метод‑экстеншенами). В общем, оператор == может превращаться в разный SQL‑код по сравнению с .Equals(), если мы имеем дело не с чистым C#, а с Expression деревьями.

Момент третий: как эти отличия могут подставить

  1. Разные результаты при работе с коллекциями
    Представим, что у нас есть коллекция List<Person>, где Equals() переопределён, а == — нет. Мы хотим проверить, есть ли в списке объект, «равный» некому p1. Пишем:

    if (myList.Contains(p1)) 
    {
        // ...
    }

    Коллекция вызовет Equals() под капотом, у нас всё будет работать. Но потом кто‑то решит: «Пфф, зачем нам Contains — напишу‑ка я:

    if (myList.Any(item => item == p1))
    {
        // ...
    }

    Если operator== у Person не перегружен, это будет сравнение ссылок... А значит, в большинстве случаев возвращается false, ведь в списке другие экземпляры Person. И тесты, возможно, молчат, потому что тест мы писали на Contains, а кто‑то в другом месте написал .Any(item => item == p1).

  2. Непредсказуемое поведение при сериализации/десериализации
    После сериализации/десериализации мы получаем новые объекты, похожие на старые, но с другими ссылками. Если код полагается на == как на проверку «эквивалентности» — мы обламываемся. Объекты разные по ссылкам, хотя поля одинаковые. А вот если бы стояла проверка obj.Equals(other), всё было бы ок.

  3. Переопределили Equals, но забыли про GetHashCode()
    Не конкретно про == / .Equals(), но очень частый косяк: если Equals указывает, что два объекта равны, а GetHashCode у них разный, то в Dictionary, HashSet и прочих структурах с хэшированием могут происходить ужасающие вещи. Например, элемент может не находиться по ключу, хотя по идее там есть равный объект. Или, ещё хуже, разные объекты могут иметь одинаковый хэш, collision. Об этом нужно помнить обязательно.

  4. Разница при null‑ссылках
    Вызов a.Equals(b) упадёт с NullReferenceException, если a == null. А вот a == b при a == null будет корректно сравнивать (даже если b тоже null). Нужно не забыть: если a может быть null, перед a.Equals(b) нужно либо:

    if (a != null && a.Equals(b)) { ... }

    либо использовать object.Equals(a, b), это статический метод, который безопасно обработает null. В == же при null всё проще — условие сразу true или false, без исключений. Но, разумеется, если == перегружен криво и не проверяет null, могут быть другие сюрпризы.

Момент четвёртый: правила хорошего тона

Чтобы меньше прокалываться, обычно советуют:

  1. Если вы переопределили Equals, то перегрузите == и !=, чтобы они вели себя консистентно.
    Тогда разработчики, которые привыкли писать if (obj1 == obj2), не вляпаются в неожиданное. (Если вы считаете, что оператора == вообще не надо иметь, тогда явно сделайте его private или поломайте, чтобы компилятор показывал ошибку.)

  2. Обязательно меняем GetHashCode в соответствии с логикой Equals.
    Если объекты равны по Equals, у них должен быть одинаковый hashCode. Иначе Dictionary, HashSet или LINQ Distinct сломаются.

  3. Старайтесь не путать читателя кода: придерживайтесь одной логики.**
    Если Person у вас равен, когда Name == other.Name, то и == делайте так же. Иначе вы же сами наслоите путаницу.

  4. Учитывайте nullable.
    Если у вас obj может быть null, старайтесь аккуратно использовать object.Equals(obj1, obj2). Либо делайте проверки на null перед .Equals(). И следите, чтобы перегруженный оператор == корректно отрабатывал случай, когда left или right равно null.

Пример

Допустим, есть тип Order, который описывает заказ. У заказов есть ID (уникальный идентификатор), сумма и ещё куча полей. У нас могут быть в памяти два Order‑а, которые пришли из разных сервисов, с одинаковым ID, но указывают на разные объекты. Если мы хотим иметь нормальное равенство для Order, чтобы, скажем, искать его в списке, удалять дубликаты, передавать как ключ в Dictionary, мы переопределяем Equals, GetHashCode и, конечно, оператор ==. Пример:

public sealed class Order : IEquatable<Order>
{
    public Guid Id { get; }
    public decimal Amount { get; }
    public DateTime CreatedAt { get; }
    public string Currency { get; }

    // Допустим, у нас ещё есть куча разных полей
    // ...

    public Order(Guid id, decimal amount, DateTime createdAt, string currency)
    {
        Id = id;
        Amount = amount;
        CreatedAt = createdAt;
        Currency = currency;
    }

    // Реализуем IEquatable<Order>
    public bool Equals(Order? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        
        // Логика равенства, допустим, по ID: считаем один и тот же заказ
        // Либо решаем, что нужно сравнить еще и валюту, это бизнес-логика
        return Id == other.Id
            && Amount == other.Amount
            && CreatedAt == other.CreatedAt
            && Currency == other.Currency;
    }

    // Переопределяем object.Equals
    public override bool Equals(object? obj)
        => Equals(obj as Order);

    // Переопределяем GetHashCode
    public override int GetHashCode()
    {
        // Часто используют HashCode.Combine(...) в .NET 5+ 
        // или можно свой алгоритм
        return HashCode.Combine(Id, Amount, CreatedAt, Currency);
    }

    // Перегружаем оператор ==
    public static bool operator ==(Order? left, Order? right)
    {
        // Если оба null => считаем равными
        if (left is null && right is null) return true;
        // Если ровно один null => false
        if (left is null || right is null) return false;
        // Иначе, вызываем их Equals
        return left.Equals(right);
    }

    public static bool operator !=(Order? left, Order? right)
        => !(left == right);
}

Equals(Order? other) из IEquatable<Order> — это хороший способ сравнения без боковых кастов, он вызывается напрямую, минуя преобразования типа. Метод Equals(object? obj) делегирует проверку туда же, чтобы обеспечить совместимость с базовым object. Перегруженный operator== аккуратно обрабатывает null, избегая падений, и вызывает Equals, только если оба объекта не пустые. GetHashCode() обязателен к переопределению вместе с Equals, иначе хэш‑структуры, вроде Dictionary или HashSet, будут вести себя непредсказуемо. А сама логика равенства может базироваться либо на полном совпадении всех полей, либо — чаще — на бизнес‑ключе, например, только Id.

В результате:

var order1 = new Order(Guid.NewGuid(), 100.5m, DateTime.UtcNow, "USD");
var order2 = new Order(order1.Id, 100.5m, order1.CreatedAt, "USD");

// Сравниваем объекты
Console.WriteLine(order1 == order2);     // True
Console.WriteLine(order1.Equals(order2)); // True

// В Dictionary это тоже будет работать предсказуемо
var dict = new Dictionary<Order, string>();
dict[order1] = "Order 1";

Console.WriteLine(dict.ContainsKey(order2)); // True, т.к. order2 == order1

Вот так примерно выглядит правильный подход, если действительно нужно сравнивать объекты по их содержимому и хочется, чтобы == и .Equals() не расходились во мнениях.

Контраргументы и альтернативы

  • Может, и не надо перегружать ==. Иногда встречается философия: «== должен оставаться сравнением ссылок, чтобы избежать путаницы, а если хотим сравнивать содержимое, давайте явно звать .Equals() или EqualsByContent(...)». Такой подход можно найти в некоторых крупных проектах, особенно когда люди боятся, что перегрузка == сделает код неочевидным.

  • ReferenceEquals(a, b) всегда даёт истинную проверку на «одна ли ссылка». Это статический метод Object.ReferenceEquals. Если прям хотим проверить, что два объекта — один и тот же экземпляр, это самый надёжный способ (его нельзя переопределить).

  • Запрет оператора == — у некоторых типов, например у Task, где явно хотят предотвратить путаницу, реализация == недоступна. Или наоборот, у StringBuilder его тоже нет, чтобы не вводить в заблуждение (сравнить содержимое всё равно нужно при помощи .ToString()). То есть иногда класс специально не перегружает ==, подразумевая: мы не хотим, чтобы кто‑то нас сравнивал таким образом.


Заключение

Многие начинающие (да и не только).NET‑разработчики сталкиваются с неожиданным поведением == и .Equals(), ведь в жизни классы могут перегружать оператор, структуры ведут себя иначе, у строк своя особая логика, а ещё есть прокси‑объекты (Remoting, ORM, сериализация). Всё это добавляет изрядной веселухи и может привести к непредсказуемым багам, особенно если кто‑то невзначай перегрузил ==, а .Equals() — переопределил по‑другому (или вовсе забыл про него).

Моя субъективная рекомендация: если ваш класс — полноценный Value Object (в духе DDD) или сущность, равенство которой имеет смысл сравнивать по данным, смело переопределяйте Equals, GetHashCode и == одновременно и делайте это единообразно. Если же объект по сути сервисный, и «равенство» для него не важно, лучше не трогайте оператор ==, чтобы разработчики не путались. А когда нужно проверить, одна ли это ссылка, используйте ReferenceEquals(obj1, obj2). Так вы избежите неприятных сюрпризов, когда вдруг выяснится, что «одинаковые» объекты считались разными — или наоборот.

Статья подготовлена в рамках онлайн‑курса «C# Developer. Basic». На странице курса можно ознакомиться с подробной программой, а также посмотреть записи открытых уроков.

Все открытые уроки по программированию и не только можно увидеть в календаре мероприятий.

Теги:
Хабы:
+13
Комментарии39

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS