Привет, Хабр! Когда речь заходит о сравнении объектов, все почему‑то решают, что это элементарный вопрос: ну есть же ==
и есть .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 деревьями.
Момент третий: как эти отличия могут подставить
Разные результаты при работе с коллекциями
Представим, что у нас есть коллекция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)
.Непредсказуемое поведение при сериализации/десериализации
После сериализации/десериализации мы получаем новые объекты, похожие на старые, но с другими ссылками. Если код полагается на==
как на проверку «эквивалентности» — мы обламываемся. Объекты разные по ссылкам, хотя поля одинаковые. А вот если бы стояла проверкаobj.Equals(other)
, всё было бы ок.Переопределили
Equals
, но забыли проGetHashCode()
Не конкретно про==
/.Equals()
, но очень частый косяк: еслиEquals
указывает, что два объекта равны, аGetHashCode
у них разный, то вDictionary
,HashSet
и прочих структурах с хэшированием могут происходить ужасающие вещи. Например, элемент может не находиться по ключу, хотя по идее там есть равный объект. Или, ещё хуже, разные объекты могут иметь одинаковый хэш, collision. Об этом нужно помнить обязательно.Разница при 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, могут быть другие сюрпризы.
Момент четвёртый: правила хорошего тона
Чтобы меньше прокалываться, обычно советуют:
Если вы переопределили
Equals
, то перегрузите==
и!=
, чтобы они вели себя консистентно.
Тогда разработчики, которые привыкли писатьif (obj1 == obj2)
, не вляпаются в неожиданное. (Если вы считаете, что оператора==
вообще не надо иметь, тогда явно сделайте егоprivate
или поломайте, чтобы компилятор показывал ошибку.)Обязательно меняем
GetHashCode
в соответствии с логикойEquals
.
Если объекты равны поEquals
, у них должен быть одинаковый hashCode. ИначеDictionary
,HashSet
илиLINQ Distinct
сломаются.Старайтесь не путать читателя кода: придерживайтесь одной логики.**
ЕслиPerson
у вас равен, когдаName == other.Name
, то и==
делайте так же. Иначе вы же сами наслоите путаницу.Учитывайте 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». На странице курса можно ознакомиться с подробной программой, а также посмотреть записи открытых уроков.
Все открытые уроки по программированию и не только можно увидеть в календаре мероприятий.