equals і hashCode тісно пов'язані між собою, і що обидва ці методи бажано перевизначати у своїх класах узгоджено. Трохи менша кількість знає, чому це так і які сумні наслідки можуть бути, якщо порушити це правило. Пропоную розглянути концепцію цих методів, повторити їх призначення і розібратися, чому вони так пов'язані.
Цю статтю, як і попередню про завантаження класів, я писав для себе, щоб остаточно розкрити всі деталі питання і більше не повертатися до сторонніх джерел. Тому буду радий конструктивній критиці, бо якщо десь є прогалини, їх слід усунути. Стаття, на жаль, вийшла досить об'ємною.
Правила перевизначення equals
Методequals() необхідний у Java для підтвердження чи заперечення того факту, що два об'єкти одного походження є логічно рівними. Тобто, порівнюючи два об'єкти, програмісту необхідно зрозуміти, еквівалентні чи їхні значущі поля. Не обов'язково всі поля повинні бути ідентичними, оскільки метод equals() передбачає саме логічну рівність.
Але іноді немає особливої потреби в використанні цього методу. Як кажуть, найпростіший спосіб уникнути проблем, використовуючи той чи інший механізм — не використовувати його. Також слід зазначити, що одного разу порушивши контракт equals, ви втрачаєте контроль над розумінням того, як інші об'єкти і структури будуть взаємодіяти з вашим об'єктом. І надалі знайти причину помилки буде досить складно.
Коли не варто перевизначати цей метод
- Коли кожен екземпляр класу є унікальним. У більшій мірі це стосується тих класів, які надають певну поведінку, а не призначені для роботи з даними. Наприклад, клас
- Коли насправді від класу не потрібно визначати еквівалентність його екземплярів. Наприклад, для класу
- Коли клас, який ви розширюєте, вже має свою реалізацію методу
equalsі поведінка цієї реалізації вас влаштовує.
Наприклад, для класів - І, нарешті, немає необхідності перекривати
equals, коли область видимості вашого класу єprivateабоpackage-privateі ви впевнені, що цей метод ніколи не буде викликаний.
Thread. Для них реалізація методу equals, надана класом Object, більш ніж достатня. Інший приклад — класи перерахувань (Enum).
java.util.Random взагалі немає необхідності порівнювати між собою екземпляри класу, визначаючи, чи можуть вони повернути однакову послідовність випадкових чисел. Просто тому, що природа цього класу навіть не передбачає таку поведінку.
Set, List, Map реалізація equals знаходиться в AbstractSet, AbstractList і AbstractMap відповідно.
Контракт equals
При перевизначенні методуequals розробник повинен дотримуватися основних правил, визначених у специфікації мови Java.
- Рефлексивність для будь-якого заданого значення
- Симетричність для будь-яких заданих значень
- Транзитивність для будь-яких заданих значень
- Узгодженість для будь-яких заданих значень
- Порівняння null для будь-якого заданого значення
x, вираз x.equals(x) повинен повертати true.Заданого — мається на увазі такого, що
x != null
x і y, x.equals(y) повинно повертати true тільки у тому випадку, коли y.equals(x) повертає true.
x, y і z, якщо x.equals(y) повертає true і y.equals(z) повертає true, x.equals(z) повинно повернути значення true.
x і y повторний виклик x.equals(y) буде повертати значення попереднього виклику цього методу за умови, що поля, використовувані для порівняння цих двох об'єктів, не змінювалися між викликами.
x виклик x.equals(null) повинен повертати false.
Порушення контракту equals
Багато класів, наприклад класи з Java Collections Framework, залежать від реалізації методуequals(), тому не варто нехтувати ним, бо порушення контракту цього методу може призвести до нераціональної роботи застосунку, і в такому разі знайти причину буде досить важко.
Згідно з принципом рефлексивності, кожен об'єкт має бути еквівалентним самому собі. Якщо цей принцип буде порушений, при додаванні об'єкта в колекцію і при подальшому пошуку його за допомогою методу contains() ми не зможемо знайти той об'єкт, який щойно поклали в колекцію.
Умова симетричності каже, що два будь-яких об'єкти мають бути рівними незалежно від того, в якому порядку вони будуть порівнюватися. Наприклад, маючи клас, що містить лише одне поле рядкового типу, буде неправильно порівнювати у методі equals це поле зі строкою. Бо у випадку зворотного порівняння метод завжди поверне значення false.
// Порушення симетричності
public class SomeStringify {
private String s;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof SomeStringify) {
return s.equals(((SomeStringify) o).s);
}
// порушення симетричності, класи різного походження
if (o instanceof String) {
return s.equals(o);
}
return false;
}
}
//Правильне визначення методу equals
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof SomeStringify &&
((SomeStringify) o).s.equals(s);
}
З умови транзитивності виходить, що якщо будь-які два з трьох об'єктів рівні, то у такому разі мають бути рівними всі три. Цей принцип легко порушити у тому випадку, коли необхідно розширити базовий клас, додавши до нього значущий компонент.
Наприклад, до класу Point з координатами x та y необхідно додати колір точки, розширивши його. Для цього потрібно оголосити клас ColorPoint з відповідним полем color. Таким чином, якщо у розширеному класі викликати метод equals батька, а в батьківському будемо вважати, що порівнюються лише координати x та y, тоді дві точки різного кольору, але з однаковими координатами будуть вважатися рівними, що неправильно.
У такому разі потрібно навчити похідний клас відрізняти кольори. Для цього можна скористатися двома способами. Але один буде порушувати правило симетричності, а другий — транзитивності.
// Перший спосіб, порушуючи симетричність
// Метод перевизначений у класі ColorPoint
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
У цьому випадку виклик point.equals(colorPoint) поверне значення true, а порівняння colorPoint.equals(point) —false, бо очікує об'єкт "свого" класу. Таким чином і порушується правило симетричності.
Другий спосіб має на увазі робити "сліпу" перевірку, якщо немає даних про колір точки, тобто маємо клас Point. Або ж перевіряти колір, якщо інформація про нього доступна, тобто порівнювати об'єкт класу ColorPoint.
// Метод перевизначений у класі ColorPoint
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
// Сліпа перевірка
if (!(o instanceof ColorPoint))
return super.equals(o);
// Повна перевірка, включаючи колір точки
return super.equals(o) && ((ColorPoint) o).color == color;
}
Принцип транзитивності тут порушується наступним чином. Припустимо, є визначення наступних об'єктів:
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
Таким чином, хоч і виконується рівність p1.equals(p2) і p2.equals(p3), p1.equals(p3) поверне значення false. При цьому другий спосіб, на мій погляд, виглядає менш привабливим, оскільки в деяких випадках алгоритм може осліпнути й не виконати порівняння повністю, а ви про це можете й не дізнатися.
Трохи лірики
Взагалі-то конкретного рішення цієї проблеми, як я зрозумів, немає. Є думка одного авторитетного автора на ім'я Кей Хорстманн, що можна замінити використання оператора instanceof на виклик методу getClass(), який повертає клас об'єкта, і, перш ніж почати порівнювати самі об'єкти, переконатися, що вони одного типу, а на факт їх загального походження не звертати уваги. Таким чином, правила симетричності і транзитивності будуть виконані.
Але при цьому на іншій стороні барикад стоїть ще один не менш шанований у широких колах автор Джошуа Блох, який вважає, що такий підхід порушує принцип підстановки Барбари Лісков. Цей принцип свідчить, що “код, що викликає, повинен працювати з базовим класом точно так само, як і з його підкласами, не знаючи про це”. І в рішенні, запропонованому Хорстманном, цей принцип явно порушується, бо залежить від реалізації.
Коротше, справа ясна, що справа темна. Слід також зазначити, що Хорстманн уточнює правило застосування свого підходу й англійською по білому пише, що потрібно визначитися зі стратегією під час проєктування класів, і якщо перевірка на рівність проводитиметься тільки силами суперкласу, можна це робити, виконуючи операцію instanceof. Інакше, коли семантика перевірки змінюється залежно від похідного класу, і реалізацію методу потрібно спустити вниз по ієрархії, необхідно використовувати метод getClass().
Джошуа Блох, своєю чергою, пропонує відмовитися від наслідування і скористатися композицією об'єктів, включивши до складу класу ColorPoint клас Point і надавши метод доступу asPoint() для отримання інформації конкретно про точку. Це дозволить уникнути порушення всіх правил, але, як на мене, ускладнить розуміння коду.
Третій варіант — скористатися автоматичною генерацією методу equals за допомогою IDE. Idea, до речі, відтворює генерацію за Хорстманном, причому дозволяючи обрати стратегію реалізації методу в суперкласі або в його спадкоємцях.
І, нарешті, наступне правило узгодженості свідчить, що якщо об'єкти x і y не змінюються, повторний виклик x.equals(y) повинен повернути те саме значення, що й раніше.
Останнє правило полягає в тому, що жоден об'єкт не повинен бути дорівнювати null. Тут усе зрозуміло, null — це невизначеність, чи рівний об'єкт невизначеності? Н незрозуміло, тобто false.
Загальний алгоритм визначення equals
- Перевірити на рівність посилання об'єктів
thisі параметра методуo.
if (this == o) return true; - Перевірити, чи визначено посилання
o, тобто чи є воноnull.
Якщо надалі під час порівняння типів об'єктів буде використовуватися операторinstanceof, цей пункт можна пропустити, оскільки цей параметр повертаєfalseв цьому випадкуnull instanceof Object. - Порівняти типи об'єктів
thisіoза допомогою оператораinstanceofабо методуgetClass(), керуючись описом вище і власним чуттям. - Якщо метод
equalsперевизначається в підкласі, не забудьте зробити викликsuper.equals(o) - Виконати перетворення типу параметра
oдо потрібного класу. - Виконати порівняння всіх значущих полів об'єктів:
- для примітивних типів (крім
floatіdouble), використовуючи оператор== - для референсних полів необхідно викликати їхній метод
equals - для масивів можна скористатися перебором у циклі або методом
Arrays.equals() - для типів
floatіdoubleнеобхідно використовувати методи порівняння відповідних обгорткових класівFloat.compare()іDouble.compare()
- для примітивних типів (крім
- І, нарешті, відповісти на три питання: чи є реалізований метод симетричним? Транзитивним? Узгодженим? Два інших принципи (рефлексивність і визначеність), як правило, виконуються автоматично.
Правила перевизначення hashCode
Хеш — це деяке число, згенероване на основі об'єкта й описує його стан у якийсь момент часу. Це число використовується в Java переважно в хеш-таблицях, таких якHashMap. При цьому хеш-функція отримання числа на основі об'єкта має бути реалізована таким чином, щоб забезпечити відносно рівномірний розподіл елементів по хеш-таблиці. А також мінімізувати ймовірність появи колізій, коли за різними ключами функція поверне однакове значення.
Контракт hashCode
Для реалізації хеш-функції в специфікації мови визначені наступні правила:- виклик методу
hashCodeодин і більше разів над одним і тим самим об'єктом має повертати одне й те саме хеш-значення, за умови що поля об'єкта, які беруть участь у обчисленні значення, не змінювалися. - виклик методу
hashCodeнад двома об'єктами має завжди повертати одне й те саме число, якщо ці об'єкти рівні (виклик методуequalsдля цих об'єктів повертаєtrue). - виклик методу
hashCodeнад двома об'єктами, які не рівні між собою, має повертати різні хеш-значення. Хоча ця вимога і не є обов'язковою, слід враховувати, що її виконання позитивно вплине на продуктивність роботи хеш-таблиць.
Методи equals і hashCode необхідно перевизначати разом
Виходячи з описаних вище контрактів, слід, що перевизначаючи у своєму коді методequals, необхідно завжди перевизначати і метод hashCode. Оскільки фактично два екземпляри класу відрізняються, бо знаходяться в різних областях пам'яті, порівнювати їх доводиться за деякими логічними ознаками. Відповідно, два логічно еквівалентних об'єкти мають повертати однакове значення хеш-функції.
Що станеться, якщо буде перевизначено лише один з цих методів?
equalsє,hashCodeнемаєПрипустимо ми правильно визначили метод
equalsу нашому класі, а методhashCodeвирішили залишити як він є у класіObject. Тоді з точки зору методуequalsдва об'єкти будуть логічно рівними, у той час як з точки зору методуhashCodeвони не матимуть нічого спільного. І, таким чином, розміщуючи якийсь об'єкт в хеш-таблицю, ми ризикуємо не отримати його назад за ключем.
Наприклад, так:Map<Point, String> m = new HashMap<>(); m.put(new Point(1, 1), "Point A"); // pointName == null String pointName = m.get(new Point(1, 1));Очевидно, що розміщений і шуканий об'єкт — це два різні об'єкти, хоча вони й є логічно рівними. Але, оскільки вони мають різне хеш-значення, тому що ми порушили контракт, можна сказати, що ми втратили свій об'єкт десь у глибинах хеш-таблиці.
hashCodeє,equalsнемає.Що буде якщо ми перевизначимо метод
hashCode, а реалізацію методуequalsуспадкуємо з класуObject. Як відомо методequalsза замовчуванням просто порівнює вказівники на об'єкти, визначаючи, чи посилаються вони на один і той самий об'єкт. Припустимо, що методhashCodeми написали за всіма канонами, а саме — згенерували засобами IDE, і він буде повертати однакові хеш-значення для логічно однакових об'єктів. Очевидно, що тим самим ми вже визначили певний механізм порівняння двох об'єктів.
Відповідно, приклад з попереднього пункту по ідеї має виконуватися. Але ми, як і раніше, не зможемо знайти наш об'єкт у хеш-таблиці. Хоча будемо вже близько до цього, тому що як мінімум знайдемо корзину хеш-таблиці, у якій об'єкт буде лежати.
Для успішного пошуку об'єкта у хеш-таблиці, окрім порівняння хеш-значень ключа, використовується також визначення логічної рівності ключа із шуканим об'єктом. Тобто без перевизначення методу
equalsніяк не обійтися.
Загальний алгоритм визначення hashCode
Тут, мені здається, взагалі не варто сильно переживати і виконати генерацію методу у своїй улюбленій IDE. Тому що всі ці зміщення бітів вправо, вліво у пошуках золотого перетину, тобто нормального розподілу — це для зовсім упоротих чуваків. Особисто я сумніваюся, що зможу зробити краще і швидше, ніж та ж Idea.Замість висновку
Таким чином, ми бачимо, що методиequals і hashCode грають чітко визначену роль у мові Java і призначені для отримання характеристики логічної рівності двох об'єктів. У випадку з методом equals це має пряме відношення до порівняння об'єктів, у випадку з hashCode опосередковане, коли необхідно, скажімо так, визначити приблизне розташування об'єкта у хеш-таблицях або подібних структурах даних з метою збільшення швидкості пошуку об'єкта.
Окрім контрактів equals і hashCode існує ще одна вимога, що стосується порівняння об'єктів. Це узгодженість методу compareTo інтерфейсу Comparable з методом equals. Ця вимога зобов'язує розробника завжди повертати x.equals(y) == true, коли x.compareTo(y) == 0. Тобто ми бачимо, що логічне порівняння двох об'єктів не повинно суперечити ніде в застосунку і завжди бути узгодженим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ