1. Вступ
У програмуванні ми постійно щось порівнюємо: числа, рядки, обʼєкти. Але що насправді означає «рівність» обʼєктів у C#? Це не завжди очевидно. Сьогодні розглянемо два ключові методи, що визначають, як порівнюються обʼєкти: Equals() і GetHashCode(). Розуміння цих методів вкрай важливе для коректної роботи ваших програм, особливо під час використання колекцій на кшталт Dictionary або HashSet.
У C# існує два основних види рівності:
Рівність посилань (Reference Equality):Означає, що дві змінні посилального типу вказують на один і той самий обʼєкт у памʼяті. Це перевіряється оператором == для посилальних типів.
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false (різні об'єкти в пам'яті)
Console.WriteLine(obj1 == obj3); // true (обидва посилання вказують на один і той самий об'єкт)
Рівність значень (Value Equality):
Означає, що два різні обʼєкти (або два типи значень) мають однаковий вміст (значення своїх полів/властивостей). Це те, чого зазвичай прагнуть під час порівняння обʼєктів. Для цього використовують метод Equals().
// Припустимо, у нас є клас Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1 і p2 — різні об'єкти, але ми хочемо, щоб вони вважалися "рівними" за значенням
Console.WriteLine(p1.Equals(p2)); // ? Залежить від реалізації Equals()
Console.WriteLine(p1.Equals(p3)); // ?
2. Метод Equals()
Метод Equals() визначений у базовому класі System.Object, від якого успадковуються всі типи в C#. Його основне призначення — визначити, чи рівні два обʼєкти за значенням.
Поведінка Equals() за замовчуванням
Для типів значень (struct, int, bool тощо): Реалізація Equals() за замовчуванням (успадкована від System.ValueType) виконує побітове порівняння усіх полів. Якщо всі поля рівні, обʼєкти вважаються рівними. Це зазвичай працює так, як очікується.
Для посилальних типів (class, string, array тощо): Реалізація Equals() за замовчуванням (успадкована від System.Object) перевіряє рівність посилань. Тобто obj1.Equals(obj2) за замовчуванням поверне true тільки якщо obj1 і obj2 вказують на один і той самий обʼєкт у памʼяті.class Person // Посилальний тип
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // Інший об'єкт, але той самий вміст
Console.WriteLine(person1.Equals(person2)); // false (за замовчуванням порівнює посилання)
Як бачите, поведінка за замовчуванням для посилальних типів часто не те, що потрібно. Ми хочемо, щоб дві «Аліси» віком 30 років вважалися рівними, навіть якщо це різні обʼєкти в памʼяті.
Перевизначення Equals() для користувацьких класів
Щоб забезпечити рівність за значенням для власних класів, ви маєте перевизначити метод Equals().
Правила перевизначення Equals() (контракт):
- Рефлексивність: x.Equals(x) завжди true.
- Симетричність: Якщо x.Equals(y) дорівнює true, то y.Equals(x) теж має бути true.
- Транзитивність: Якщо x.Equals(y) і y.Equals(z) обидва true, то x.Equals(z) теж має бути true.
- Послідовність: Багаторазові виклики x.Equals(y) мають давати той самий результат, доки обʼєкти не зміняться.
- Null-сумісність: x.Equals(null) завжди false.
Приклад: перевизначення Equals() для класу Person
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Перевизначення Equals()
public override bool Equals(object? obj)
{
// 1. Перевірка на null
if (obj == null) return false;
// 2. Перевірка на той самий тип
if (obj.GetType() != this.GetType()) return false;
// 3. Перетворення типу
Person other = (Person)obj;
// 4. Порівняння полів за значенням
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// Використання:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true (тепер порівнює за значенням!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. Метод GetHashCode()
Метод GetHashCode() також визначений у System.Object. Він повертає цілочисельне значення (хеш-код), яке швидко та (наскільки можливо) унікально ідентифікує обʼєкт.
Для чого потрібен GetHashCode()?
Хеш-коди використовують для оптимізації роботи з колекціями, що базуються на хеш-таблицях. До таких колекцій належать:
- Dictionary<TKey, TValue> (хеш-код використовують для швидкого пошуку ключа)
- HashSet<T> (хеш-код використовують для перевірки унікальності елементів)
- Hashtable
Коли ви додаєте обʼєкт у HashSet або використовуєте його як ключ у Dictionary, колекція спочатку обчислює хеш-код обʼєкта. Це дає змогу одразу «стрибнути» до певного «кошику» або групи елементів, які мають той самий хеш-код, замість того щоб перебирати всі елементи. Після цього всередині цього «кошику» вже використовують метод Equals() для точного порівняння.
Правила перевизначення GetHashCode() (контракт):
Якщо ви перевизначаєте Equals(), ви МАЄТЕ перевизначити і GetHashCode()! Це одне з найважливіших правил C#.
- Послідовність: Якщо Equals() повертає true для двох обʼєктів, то GetHashCode() для цих обʼєктів має повертати те саме значення. (Зворотне невірне: різні обʼєкти можуть мати однакові хеш-коди — це називається «колізією».)
- Стабільність: GetHashCode() має повертати те саме значення для одного й того самого обʼєкта, доки його поля, що беруть участь у порівнянні, не зміняться.
- Швидкість: GetHashCode() має бути швидким і не вимагати значних обчислень.
Чому це так важливо? Якщо ви перевизначили Equals(), але не GetHashCode(), хеш-колекції працюватимуть некоректно:
- Dictionary може не знайти ваш ключ.
- HashSet додаватиме дублікати, «вважаючи», що вони унікальні.
Це відбувається тому, що за замовчуванням GetHashCode() повертає хеш, заснований на посиланні на обʼєкт (для посилальних типів). Якщо Equals() тепер порівнює за значенням, то обʼєкти з однаковим значенням, але різними посиланнями, матимуть різні хеш-коди, і колекція не вважатиме їх однаковими.
Перевизначення GetHashCode() для класу Person
Добра практика — генерувати хеш-код на основі тих самих полів, які використовуються в Equals(). .NET надає статичний метод HashCode.Combine(), який дуже зручний для цього.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// Перевизначення GetHashCode()
public override int GetHashCode()
{
// Використовуємо HashCode.Combine для об'єднання хешів полів.
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// Використання в колекції:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet
uniquePeople = new HashSet
(); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 вважається рівним p1 за значенням, не буде доданий Console.WriteLine($"Кількість унікальних людей: {uniquePeople.Count}"); // Виведення: 1 uniquePeople.Add(p3); Console.WriteLine($"Кількість унікальних людей: {uniquePeople.Count}"); // Виведення: 2 } }
Важливий момент: У GetHashCode() для рядкових полів, які порівнюються без урахування регістру (StringComparison.OrdinalIgnoreCase у Equals), ви маєте обчислювати хеш-код так, щоб він теж не залежав від регістру (наприклад, привести до нижнього регістру перед обчисленням хеша, як Name.ToLowerInvariant()). Інакше Equals() поверне true (Alice == alice), а GetHashCode() поверне різні значення, порушуючи контракт.
4. Перевантаження оператора == і !=
Для класів (посилальних типів) оператор == за замовчуванням перевіряє рівність посилань. Ви можете перевантажити його, щоб він перевіряв рівність значень, аналогічно Equals().
Правила перевантаження ==:
- Якщо ви перевантажуєте ==, обовʼязково перевантажте і !=.
- Рекомендується, щоб перевантажений == мав ту саму поведінку, що й Equals().
- Також потрібно перевизначити GetHashCode() і Equals() під час перевантаження ==.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// Конструктор, Equals, GetHashCode як раніше
// Перевантаження оператора ==
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // Перевіряємо, чи left дорівнює null
{
return ReferenceEquals(right, null); // Якщо обидва null — рівні
}
return left.Equals(right); // Інакше використовуємо наш перевизначений Equals()
}
// Перевантаження оператора != (обовʼязково під час перевантаження ==)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// Використання:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true (тепер використовує перевантажений ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — автоматична рівність
Починаючи з C# 9, зʼявився тип record. Це посилальний тип, але він автоматично реалізує рівність за значенням (та перевизначає Equals(), GetHashCode(), ToString() і оператори ==/!=) на основі всіх своїх полів/властивостей. Це робить record ідеальним для незмінних обʼєктів-даних.
public record PersonRecord(string Name, int Age);
// Використання:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true (автоматичне порівняння за значенням!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record значно спрощує життя, коли вам потрібна поведінка типу значення для посилального типу.
6. Рекомендації
Якщо ви перевизначаєте Equals(), завжди перевизначайте і GetHashCode()! Порушення цього правила призводить до непередбачуваної поведінки в хеш-колекціях.
Equals() і GetHashCode() мають використовувати одні й ті самі поля. Ті поля, які роблять обʼєкти рівними за значенням, слід використовувати для обчислення хеша.
Будьте обережні зі змінюваними (mutable) типами. Якщо поля, що використовуються в Equals() і GetHashCode(), можуть змінюватися після створення обʼєкта, хеш-код обʼєкта може змінитися. Це дуже погано для хеш-колекцій, бо обʼєкт може «загубитися» після зміни (його хеш-код зміниться, і колекція не зможе його знайти у своєму «кошику»). Для хеш-колекцій краще використовувати незмінні (immutable) типи як ключі або елементи.
Для незмінних обʼєктів-даних розгляньте використання record. Це значно спрощує реалізацію рівності за значенням.
Перевантажуйте == і != лише для посилальних типів там, де це має сенс. Для типів значень == уже порівнює за значенням. Якщо перевантажуєте, переконайтеся, що поведінка відповідає Equals().
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ